Repository: HenryXiaoYang/XYBotV2 Branch: main Commit: 8f8b65cd35df Files: 243 Total size: 2.0 MB Directory structure: gitextract_8ol47tx7/ ├── .dockerignore ├── .github/ │ └── workflows/ │ └── docker-build-on-commit.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── WebUI/ │ ├── __init__.py │ ├── common/ │ │ └── bot_bridge.py │ ├── config.py │ ├── forms/ │ │ ├── __init__.py │ │ └── auth_forms.py │ ├── routes/ │ │ ├── __init__.py │ │ ├── about.py │ │ ├── auth.py │ │ ├── bot.py │ │ ├── config.py │ │ ├── explorer.py │ │ ├── file.py │ │ ├── logs.py │ │ ├── overview.py │ │ ├── plugin.py │ │ └── tools.py │ ├── services/ │ │ ├── bot_service.py │ │ ├── config_service.py │ │ ├── data_service.py │ │ ├── file_service.py │ │ ├── plugin_service.py │ │ ├── tool_service.py │ │ └── websocket_service.py │ ├── static/ │ │ ├── css/ │ │ │ ├── components/ │ │ │ │ ├── cards.css │ │ │ │ ├── file_browser.css │ │ │ │ ├── file_viewer.css │ │ │ │ ├── loading.css │ │ │ │ └── modals.css │ │ │ ├── custom.css │ │ │ ├── pages/ │ │ │ │ ├── about.css │ │ │ │ ├── auth.css │ │ │ │ ├── config.css │ │ │ │ ├── explorer.css │ │ │ │ ├── logs.css │ │ │ │ ├── overview.css │ │ │ │ ├── plugin.css │ │ │ │ └── tools.css │ │ │ └── style.css │ │ └── js/ │ │ ├── components/ │ │ │ ├── cards.js │ │ │ ├── file_browser.js │ │ │ ├── file_viewer.js │ │ │ ├── loading.js │ │ │ ├── modals.js │ │ │ └── notification.js │ │ ├── logs.js │ │ ├── main.js │ │ └── pages/ │ │ ├── about.js │ │ ├── auth.js │ │ ├── config.js │ │ ├── explorer.js │ │ ├── overview.js │ │ ├── plugin.js │ │ └── tools.js │ ├── templates/ │ │ ├── about/ │ │ │ └── index.html │ │ ├── auth/ │ │ │ └── login.html │ │ ├── base.html │ │ ├── components/ │ │ │ ├── cards.html │ │ │ ├── file_browser.html │ │ │ ├── file_viewer.html │ │ │ ├── loading.html │ │ │ └── modals.html │ │ ├── config/ │ │ │ └── index.html │ │ ├── explorer/ │ │ │ ├── index.html │ │ │ └── view.html │ │ ├── logs/ │ │ │ └── index.html │ │ ├── overview/ │ │ │ └── index.html │ │ ├── plugin/ │ │ │ └── index.html │ │ └── tools/ │ │ └── index.html │ └── utils/ │ ├── async_to_sync.py │ ├── auth_utils.py │ ├── singleton.py │ └── template_filters.py ├── WechatAPI/ │ ├── Client/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── chatroom.py │ │ ├── friend.py │ │ ├── hongbao.py │ │ ├── login.py │ │ ├── message.py │ │ ├── protect.py │ │ ├── tool.py │ │ └── user.py │ ├── Server/ │ │ ├── WechatAPIServer.py │ │ └── __init__.py │ ├── __init__.py │ ├── core/ │ │ └── placeholder │ └── errors.py ├── WechatAPIDocs/ │ ├── Makefile │ ├── _templates/ │ │ └── custom-toc.html │ ├── build.sh │ ├── conf.py │ ├── index.rst │ ├── make.bat │ └── requirements.txt ├── XYBot_Dify_Template.yml ├── app.py ├── bot.py ├── database/ │ ├── XYBotDB.py │ ├── keyvalDB.py │ └── messsagDB.py ├── docker-compose.yml ├── docs/ │ ├── .nojekyll │ ├── README.md │ ├── WechatAPIClient/ │ │ ├── _modules/ │ │ │ ├── WechatAPI/ │ │ │ │ └── Client/ │ │ │ │ ├── base.html │ │ │ │ ├── chatroom.html │ │ │ │ ├── friend.html │ │ │ │ ├── hongbao.html │ │ │ │ ├── login.html │ │ │ │ ├── message.html │ │ │ │ ├── protect.html │ │ │ │ ├── tool.html │ │ │ │ └── user.html │ │ │ └── index.html │ │ ├── _sources/ │ │ │ └── index.rst.txt │ │ ├── _static/ │ │ │ ├── basic.css │ │ │ ├── debug.css │ │ │ ├── doctools.js │ │ │ ├── documentation_options.js │ │ │ ├── language_data.js │ │ │ ├── pygments.css │ │ │ ├── scripts/ │ │ │ │ ├── furo-extensions.js │ │ │ │ ├── furo.js │ │ │ │ └── furo.js.LICENSE.txt │ │ │ ├── searchtools.js │ │ │ ├── skeleton.css │ │ │ ├── sphinx_highlight.js │ │ │ ├── styles/ │ │ │ │ ├── furo-extensions.css │ │ │ │ └── furo.css │ │ │ └── translations.js │ │ ├── genindex.html │ │ ├── index.html │ │ ├── objects.inv │ │ ├── py-modindex.html │ │ ├── search.html │ │ └── searchindex.js │ ├── _coverpage.md │ ├── _sidebar.md │ ├── index.html │ └── zh_cn/ │ ├── Dify插件配置.md │ ├── Docker部署.md │ ├── Linux部署.md │ ├── Windows部署.md │ ├── 插件开发.md │ └── 配置文件.md ├── main_config.toml ├── plugins/ │ ├── AdminPoint/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── AdminSigninReset/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── AdminWhitelist/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── BotStatus/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── DependencyManager/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── Dify/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── DouyinParser/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── ExamplePlugin/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── GetContact/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── GetWeather/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── Gomoku/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── GoodMorning/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── GroupWelcome/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── Leaderboard/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── LuckyDraw/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── ManagePlugin/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── Menu/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── Music/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── News/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── PointTrade/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── QueryPoint/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── RandomMember/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── RandomPicture/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── RedPacket/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── SignIn/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── TencentLke/ │ │ ├── __init__.py │ │ ├── config.toml │ │ └── main.py │ ├── UpdateQR/ │ │ ├── __init__.py │ │ └── main.py │ └── Warthunder/ │ ├── __init__.py │ ├── config.toml │ └── main.py ├── redis.conf ├── requirements.txt └── utils/ ├── decorators.py ├── event_manager.py ├── plugin_base.py ├── plugin_manager.py ├── singleton.py └── xybot.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .idea/**/workspace.xml .idea/**/tasks.xml .idea/**/usage.statistics.xml .idea/**/dictionaries .idea/**/shelf .idea/**/aws.xml .idea/**/contentModel.xml .idea/**/dataSources/ .idea/**/dataSources.ids .idea/**/dataSources.local.xml .idea/**/sqlDataSources.xml .idea/**/dynamic.xml .idea/**/uiDesigner.xml .idea/**/dbnavigator.xml .idea/**/gradle.xml .idea/**/libraries cmake-build-*/ .idea/**/mongoSettings.xml *.iws out/ .idea_modules/ atlassian-ide-plugin.xml .idea/replstate.xml .idea/sonarlint/ com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties .idea/httpRequests .idea/caches/build_file_checksums.ser .DS_Store .AppleDouble .LSOverride Icon ._* .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk __pycache__/ *.py[cod] *$py.class *.so .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST *.manifest *.spec pip-log.txt pip-delete-this-directory.txt htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ *.mo *.pot *.log local_settings.py db.sqlite3 db.sqlite3-journal instance/ .webassets-cache .scrapy docs/_build/ .pybuilder/ target/ .ipynb_checkpoints profile_default/ ipython_config.py .pdm.toml .pdm-python .pdm-build/ __pypackages__/ celerybeat-schedule celerybeat.pid *.sage.py .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ .spyderproject .spyproject .ropeproject /site .mypy_cache/ .dmypy.json dmypy.json .pyre/ .pytype/ cython_debug/ /plugins/update_qr.py ================================================ FILE: .github/workflows/docker-build-on-commit.yml ================================================ name: Docker Multi-arch Build on Commit on: push: branches: - main paths: - 'Dockerfile' - 'docker-compose.yml' - '**.py' - 'requirements.txt' - '**.conf' - '**.toml' - '**.yml' - '.github/workflows/docker-build-on-commit.yml' - '.dockerignore' jobs: build: runs-on: ubuntu-latest steps: - name: 检出代码 uses: actions/checkout@v3 - name: 设置 QEMU 模拟器 uses: docker/setup-qemu-action@v2 - name: 设置 Docker Buildx uses: docker/setup-buildx-action@v2 with: driver-opts: | image=moby/buildkit:master install: true - name: 创建多架构构建器 run: | docker buildx create --name multiarch --use docker buildx inspect --bootstrap - name: 登录到 Docker Hub uses: docker/login-action@v3 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: 获取短 SHA id: vars run: echo "GIT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - name: 构建并推送多架构镜像 uses: docker/build-push-action@v4 with: context: . builder: multiarch push: true platforms: linux/amd64,linux/arm64 tags: | ${{ vars.DOCKERHUB_USERNAME }}/xybotv2:latest ${{ vars.DOCKERHUB_USERNAME }}/xybotv2:${{ env.GIT_SHA }} cache-from: type=registry,ref=${{ vars.DOCKERHUB_USERNAME }}/xybotv2:latest cache-to: type=inline,mode=max ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # UV # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. #uv.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/latest/usage/project/#working-with-version-control .pdm.toml .pdm-python .pdm-build/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project config .spyderproject .spyproject # Rope project config .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ # PyPI configuration file .pypirc # XYBot resource/robot_stat.json WechatAPI/Client/login_stat.json /WechatAPIDocs/_build/doctrees/environment.pickle /WechatAPIDocs/_build/doctrees/index.doctree /WechatAPIDocs/_build/html/_modules/WechatAPI/Client/base.html /WechatAPIDocs/_build/html/_modules/WechatAPI/Client/chatroom.html /WechatAPIDocs/_build/html/_modules/WechatAPI/Client/friend.html /WechatAPIDocs/_build/html/_modules/WechatAPI/Client/hongbao.html /WechatAPIDocs/_build/html/_modules/WechatAPI/Client/login.html /WechatAPIDocs/_build/html/_modules/WechatAPI/Client/message.html /WechatAPIDocs/_build/html/_modules/WechatAPI/Client/protect.html /WechatAPIDocs/_build/html/_modules/WechatAPI/Client/tool.html /WechatAPIDocs/_build/html/_modules/WechatAPI/Client/user.html /WechatAPIDocs/_build/html/_modules/index.html /WechatAPIDocs/_build/html/_sources/index.rst.txt /WechatAPIDocs/_build/html/_static/scripts/furo.js /WechatAPIDocs/_build/html/_static/scripts/furo.js.LICENSE.txt /WechatAPIDocs/_build/html/_static/scripts/furo.js.map /WechatAPIDocs/_build/html/_static/scripts/furo-extensions.js /WechatAPIDocs/_build/html/_static/styles/furo.css /WechatAPIDocs/_build/html/_static/styles/furo.css.map /WechatAPIDocs/_build/html/_static/styles/furo-extensions.css /WechatAPIDocs/_build/html/_static/styles/furo-extensions.css.map /WechatAPIDocs/_build/html/_static/basic.css /WechatAPIDocs/_build/html/_static/debug.css /WechatAPIDocs/_build/html/_static/doctools.js /WechatAPIDocs/_build/html/_static/documentation_options.js /WechatAPIDocs/_build/html/_static/file.png /WechatAPIDocs/_build/html/_static/language_data.js /WechatAPIDocs/_build/html/_static/minus.png /WechatAPIDocs/_build/html/_static/plus.png /WechatAPIDocs/_build/html/_static/pygments.css /WechatAPIDocs/_build/html/_static/searchtools.js /WechatAPIDocs/_build/html/_static/skeleton.css /WechatAPIDocs/_build/html/_static/sphinx_highlight.js /WechatAPIDocs/_build/html/_static/translations.js /WechatAPIDocs/_build/html/.buildinfo /WechatAPIDocs/_build/html/genindex.html /WechatAPIDocs/_build/html/index.html /WechatAPIDocs/_build/html/objects.inv /WechatAPIDocs/_build/html/py-modindex.html /WechatAPIDocs/_build/html/search.html /WechatAPIDocs/_build/html/searchindex.js !/plugins/update_qr.py /WechatAPI/core/XYWechatPad /resource/images/avatar/cardicon_default.png /database/keyval.db /database/message.db /database/xybot.db /.idea/inspectionProfiles/profiles_settings.xml /.idea/inspectionProfiles/Project_Default.xml /.idea/.gitignore /.idea/misc.xml /.idea/modules.xml /.idea/sqldialects.xml /.idea/vcs.xml /.idea/XYBotV2.iml ================================================ FILE: Dockerfile ================================================ FROM python:3.11-slim # 设置工作目录 WORKDIR /app # 设置环境变量 ENV TZ=Asia/Shanghai ENV IMAGEIO_FFMPEG_EXE=/usr/bin/ffmpeg # 安装系统依赖 RUN apt-get update && apt-get install -y \ ffmpeg \ redis-server \ && rm -rf /var/lib/apt/lists/* # 复制 Redis 配置 COPY redis.conf /etc/redis/redis.conf # 复制依赖文件 COPY requirements.txt . # 安装 Python 依赖 RUN pip install --no-cache-dir -r requirements.txt # 安装gunicorn和eventlet RUN pip install --no-cache-dir gunicorn eventlet # 复制应用代码 COPY . . # 创建启动脚本 RUN echo '#!/bin/bash\n\ redis-server /etc/redis/redis.conf --daemonize yes\n\ python app.py' > /app/start.sh \ && chmod +x /app/start.sh # 设置启动命令 CMD ["/app/start.sh"] ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ # 个人原因,停止维护。 # 🤖 XYBot V2 XYBot V2 是一个功能丰富的微信机器人框架,支持多种互动功能和游戏玩法。 # 免责声明 - 这个项目免费开源,不存在收费。 - 本工具仅供学习和技术研究使用,不得用于任何商业或非法行为。 - 本工具的作者不对本工具的安全性、完整性、可靠性、有效性、正确性或适用性做任何明示或暗示的保证,也不对本工具的使用或滥用造成的任何直接或间接的损失、责任、索赔、要求或诉讼承担任何责任。 - 本工具的作者保留随时修改、更新、删除或终止本工具的权利,无需事先通知或承担任何义务。 - 本工具的使用者应遵守相关法律法规,尊重微信的版权和隐私,不得侵犯微信或其他第三方的合法权益,不得从事任何违法或不道德的行为。 - 本工具的使用者在下载、安装、运行或使用本工具时,即表示已阅读并同意本免责声明。如有异议,请立即停止使用本工具,并删除所有相关文件。 # 📄 文档 ## https://henryxiaoyang.github.io/XYBotV2 # ✨ 主要功能 ## 🛠️ 基础功能 - 🤖 AI聊天 - 支持文字、图片、语音等多模态交互 - 📰 每日新闻 - 自动推送每日新闻 - 🎵 点歌系统 - 支持在线点歌 - 🌤️ 天气查询 - 查询全国各地天气 - 🎮 游戏功能 - 五子棋、战争雷霆玩家查询等 ## 💎 积分系统 - 📝 每日签到 - 支持连续签到奖励 - 🎲 抽奖系统 - 多种抽奖玩法 - 🧧 红包系统 - 群内发积分红包 - 💰 积分交易 - 用户间积分转账 - 📊 积分排行 - 查看积分排名 ## 👮 管理功能 - ⚙️ 插件管理 - 动态加载/卸载插件 - 👥 白名单管理 - 控制机器人使用权限 - 📊 积分管理 - 管理员可调整用户积分 - 🔄 签到重置 - 重置所有用户签到状态 # 🔌 插件系统 XYBot V2 采用插件化设计,所有功能都以插件形式实现。主要插件包括: - 👨‍💼 AdminPoint - 积分管理 - 🔄 AdminSignInReset - 签到重置 - 🛡️ AdminWhitelist - 白名单管理 - 🤖 Ai - AI聊天 - 📊 BotStatus - 机器人状态 - 📱 GetContact - 获取通讯录 - 🌤️ GetWeather - 天气查询 - 🎮 Gomoku - 五子棋游戏 - 🌅 GoodMorning - 早安问候 - 📈 Leaderboard - 积分排行 - 🎲 LuckyDraw - 幸运抽奖 - 📋 Menu - 菜单系统 - 🎵 Music - 点歌系统 - 📰 News - 新闻推送 - 💱 PointTrade - 积分交易 - 💰 QueryPoint - 积分查询 - 🎯 RandomMember - 随机群成员 - 🖼️ RandomPicture - 随机图片 - 🧧 RedPacket - 红包系统 - ✍️ SignIn - 每日签到 - ✈️ Warthunder - 战争雷霆查询 # 🚀 部署说明 ## 💻 Python部署 ### 🪟 Windows部署 #### 1. 环境准备 - 安装 Python 3.11: https://www.python.org/downloads/release/python-3119/ - 安装 ffmpeg: 从[ffmpeg官网](https://www.ffmpeg.org/download.html)下载并添加到环境变量 - 安装 Redis: 从[Redis](https://github.com/tporadowski/redis/releases/tag/v5.0.14.1)下载并启动服务 #### 2. 安装项目 ```bash git clone https://github.com/HenryXiaoYang/XYBotV2.git cd XYBotV2 python -m venv venv .\venv\Scripts\activate pip install -r requirements.txt ``` #### 3. 启动机器人 ```bash start redis-server python app.py ``` ### 🐧 Linux部署 #### 1. 环境准备 ```bash sudo apt update sudo apt install python3.11 python3.11-venv redis-server ffmpeg sudo systemctl start redis sudo systemctl enable redis ``` #### 2. 安装项目 ```bash git clone https://github.com/HenryXiaoYang/XYBotV2.git cd XYBotV2 python3.11 -m venv venv source venv/bin/activate pip install -r requirements.txt ``` #### 3. 启动机器人 ```bash python app.py ``` ### 🌌 无WebUI简单启动 如果你不需要WebUI界面,可以直接使用bot.py: ```bash python bot.py ``` ## ⚙️ 配置说明 - 主配置: main_config.toml - 插件配置: plugins/all_in_one_config.toml 这几个插件需要配置API密钥: - 🤖 Ai - 🌤️ GetWeather ## ❓ 常见问题 1. 与网络相关的报错 - 检查网络连接 - 关闭代理软件 - 重启XYBot和Redis 2. `正在运行`相关的报错 - 将占用9000端口的进程结束 3. 无法访问Web界面 - 确保9999端口已开放 - 配置防火墙允许9999端口 # 💻 代码提交 提交代码时请使用 `feat: something` 作为说明,支持的标识如下: - `feat` 新功能(feature) - `fix` 修复bug - `docs` 文档(documentation) - `style` 格式(不影响代码运行的变动) - `ref` 重构(即不是新增功能,也不是修改bug的代码变动) - `perf` 性能优化(performance) - `test` 增加测试 - `chore` 构建过程或辅助工具的变动 - `revert` 撤销 ================================================ FILE: WebUI/__init__.py ================================================ import logging import os from datetime import datetime from pathlib import Path from typing import Dict, Tuple, Union from flask import Flask, redirect, url_for from flask_login import LoginManager from flask_session import Session from flask_socketio import SocketIO from loguru import logger class InterceptHandler(logging.Handler): def emit(self, record: logging.LogRecord) -> None: logger_opt = logger.opt(depth=6, exception=record.exc_info) logger_opt.log('WEBUI', record.getMessage()) def _configure_logging(app: Flask) -> None: # 清除Flask默认日志处理器并配置使用loguru app.logger.handlers = [] app.logger.propagate = False app.logger.addHandler(InterceptHandler()) # 同样为werkzeug日志配置loguru werkzeug_logger = logging.getLogger('werkzeug') werkzeug_logger.handlers = [] werkzeug_logger.propagate = False werkzeug_logger.addHandler(InterceptHandler()) def _setup_instance_directories(app: Flask) -> None: try: os.makedirs(app.instance_path, exist_ok=True) os.makedirs(app.config['SESSION_FILE_DIR'], exist_ok=True) logger.log('WEBUI', f"已创建实例目录: {app.instance_path}") except OSError as e: logger.log('WEBUI', f"创建实例目录失败: {e}") def create_app() -> Tuple[Flask, SocketIO]: """创建并配置Flask应用实例及SocketIO实例 Returns: tuple: 包含配置好的Flask应用实例和SocketIO实例 """ # 创建Flask应用,设置静态文件和模板路径 app = Flask(__name__, instance_relative_config=True, static_folder='static', template_folder='templates') # 配置日志系统 _configure_logging(app) logger.log('WEBUI', "正在初始化XYBotV2 WebUI应用...") # 加载配置 app.config.from_pyfile(Path(__file__).resolve().parent / 'config.py') logger.log('WEBUI', "已加载WEBUI配置") # 确保实例文件夹存在 _setup_instance_directories(app) # 初始化Flask-Session Session(app) # 初始化Flask-Login login_manager = LoginManager() login_manager.init_app(app) login_manager.login_view = 'auth.login' # 注册模板过滤器 from .utils.template_filters import register_template_filters register_template_filters(app) # 注册蓝图 from .routes import register_blueprints register_blueprints(app) logger.log('WEBUI', "已注册路由蓝图,模板过滤器") # 初始化WebSocket服务 from .services.websocket_service import socketio, init_websocket # 配置socketio socketio_config = { 'cors_allowed_origins': '*', # 允许的跨域来源,生产环境应该更严格 'async_mode': 'eventlet', # 使用eventlet作为异步模式 'logger': False, # 禁用socketio日志 'engineio_logger': False # 禁用engineio日志 } # 初始化socketio socketio.init_app(app, **socketio_config) logger.log('WEBUI', "已初始化会话管理,用户认证系统,SocketIO服务") # 启动WebSocket服务 init_websocket() logger.log('WEBUI', "已启动WebSocket日志监控") # 注册全局上下文处理器 @app.context_processor def inject_global_vars() -> Dict[str, Union[str, datetime]]: """为所有模板注入全局变量 Returns: 包含全局变量的字典 """ return { 'app_name': 'XYBotV2 WebUI', 'version': '1.0.0', 'now': datetime.now() } # 定义用户加载函数 @login_manager.user_loader def load_user(user_id): pass # 简单的首页路由(重定向到概览页) @app.route('/') def index(): """应用首页路由处理,重定向到概览页""" return redirect(url_for('overview.index')) logger.log('WEBUI', "XYBotV2 WebUI应用初始化完成") return app, socketio ================================================ FILE: WebUI/common/bot_bridge.py ================================================ import asyncio import os import sys from pathlib import Path from typing import List, Dict, Any, Optional from loguru import logger from WebUI.utils.singleton import Singleton # 引入键值数据库 from database.keyvalDB import KeyvalDB from utils.plugin_manager import PluginManager # 确保可以导入根目录模块 ROOT_DIR = Path(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) sys.path.insert(0, str(ROOT_DIR)) # 键值数据库键名常量 KEY_MESSAGE_COUNT = "bot:stats:message_count" KEY_USER_COUNT = "bot:stats:user_count" KEY_LOG_POSITION = "bot:logs:last_position" def get_or_create_eventloop(): """ 获取当前线程的事件循环,如果不存在则创建一个新的 返回: asyncio.AbstractEventLoop: 事件循环对象 """ try: # 尝试获取当前线程的事件循环 loop = asyncio.get_event_loop() except RuntimeError: # 如果当前线程没有事件循环,则创建一个新的 loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) return loop class BotBridge(metaclass=Singleton): def __init__(self): # 个人资料数据 self.avatar_url = "" self.nickname = "" self.wxid = "" self.alias = "" self.is_running = False self.start_time = float(0) # 缓存数据 self._cache = {} self._cache_time = 0 self._cache_ttl = 5 # 缓存有效期(秒) # 存储正在运行的任务 self._tasks = [] # 初始化插件管理器 self.plugin_manager = PluginManager() # 配置目录 self.config_dir = ROOT_DIR / 'plugins' # 初始化数据库 self._db = KeyvalDB() # 异步初始化数据库 loop = get_or_create_eventloop() loop.run_until_complete(self._db.initialize()) async def get_message_count(self): """ 获取接收消息数量 """ try: result = await self._db.get(KEY_MESSAGE_COUNT) count = int(result) if result is not None else 0 return count except Exception as e: logger.log('WEBUI', f"获取消息计数失败: {str(e)}") return 0 async def increment_message_count(self, amount=1): """ 增加消息计数 """ try: current = await self.get_message_count() new_count = current + amount await self._db.set(KEY_MESSAGE_COUNT, str(new_count)) return True except Exception as e: logger.log('WEBUI', f"增加消息计数失败: {str(e)}") return False async def get_user_count(self): """ 获取用户数量 """ try: result = await self._db.get(KEY_USER_COUNT) count = int(result) if result is not None else 0 return count except Exception as e: logger.log('WEBUI', f"获取用户计数失败: {str(e)}") return 0 async def increment_user_count(self, amount=1): """ 增加用户计数 """ try: current = await self.get_user_count() new_count = current + amount await self._db.set(KEY_USER_COUNT, str(new_count)) return True except Exception as e: logger.log('WEBUI', f"增加用户计数失败: {str(e)}") return False async def get_start_time(self): """ 获取机器人启动时间 """ try: return self.start_time except Exception as e: logger.log('WEBUI', f"获取启动时间失败: {str(e)}") return 0 async def save_log_position(self, position): """ 保存日志读取位置到数据库 """ try: await self._db.set(KEY_LOG_POSITION, str(position)) return True except Exception as e: logger.log('WEBUI', f"保存日志位置失败: {str(e)}") return False async def get_log_position(self): """ 获取日志读取位置 """ try: pos = await self._db.get(KEY_LOG_POSITION) return int(pos) if pos is not None else 0 except Exception as e: logger.log('WEBUI', f"获取日志位置失败: {str(e)}") return 0 def save_profile(self, avatar_url: str = "", nickname: str = "", wxid: str = "", alias: str = ""): """保存个人资料信息""" self.avatar_url = avatar_url self.nickname = nickname self.wxid = wxid self.alias = alias def get_profile(self): """获取个人资料信息""" if self.is_running: return { "avatar": self.avatar_url, # 注意这里改为avatar以匹配前端期望 "nickname": self.nickname, "wxid": self.wxid, "alias": self.alias } else: return { "avatar": "", "nickname": "", "wxid": "", "alias": "" } def _create_task(self, coro): loop = get_or_create_eventloop() task = loop.create_task(coro) self._tasks.append(task) # 添加完成回调,完成后从列表移除 def _done_callback(t): if t in self._tasks: self._tasks.remove(t) # 检查是否有异常 if t.exception() is not None: logger.log('WEBUI', f"任务执行异常: {t.exception()}") task.add_done_callback(_done_callback) return task def get_all_plugins(self) -> List[Dict[str, Any]]: """ 获取所有插件信息 返回: List[Dict[str, Any]]: 插件信息列表 """ try: # 使用同步方式执行异步操作,避免coroutine未等待问题 loop = get_or_create_eventloop() if loop.is_running(): # 如果循环正在运行,可能是在事件循环内调用,创建任务 refresh_task = self._create_task(self.plugin_manager.refresh_plugins()) # 但注意这种情况下,操作可能尚未完成 else: # 否则阻塞等待完成 loop.run_until_complete(self.plugin_manager.refresh_plugins()) plugins = self.plugin_manager.get_plugin_info() # 格式化插件信息 formatted_plugins = [] for plugin in plugins: directory = plugin.get("directory", "unknown") try: dir_path = ROOT_DIR / 'plugins' / directory rel_path = dir_path.relative_to(ROOT_DIR).as_posix() except (ValueError, TypeError): rel_path = directory formatted_plugin = { "name": plugin["name"], "description": plugin["description"], "author": plugin["author"], "version": plugin["version"], "enabled": plugin["enabled"], "directory": rel_path } formatted_plugins.append(formatted_plugin) return formatted_plugins except Exception as e: logger.log('WEBUI', f"获取插件信息出错: {str(e)}") return [] def get_plugin_details(self, plugin_name: str) -> Optional[Dict[str, Any]]: """获取指定插件的详细信息""" try: plugin_info = self.plugin_manager.get_plugin_info(plugin_name) if not plugin_info: return None # 获取相对路径 directory = plugin_info.get("directory", "unknown") try: dir_path = ROOT_DIR / 'plugins' / directory rel_path = dir_path.relative_to(ROOT_DIR).as_posix() except (ValueError, TypeError): rel_path = directory return { "name": plugin_info["name"], "description": plugin_info["description"], "author": plugin_info["author"], "version": plugin_info["version"], "enabled": plugin_info["enabled"], "directory": rel_path } except Exception as e: logger.log('WEBUI', f"获取插件详情出错: {str(e)}") return None async def enable_plugin(self, plugin_name: str) -> bool: """启用插件""" if not self.is_running: raise Exception("机器人未运行") try: return await self.plugin_manager.load_plugin(plugin_name) except Exception as e: logger.log('WEBUI', f"启用插件出错: {str(e)}") return False async def disable_plugin(self, plugin_name: str) -> bool: """禁用插件""" if not self.is_running: raise Exception("机器人未运行") try: return await self.plugin_manager.unload_plugin(plugin_name) except Exception as e: logger.log('WEBUI', f"禁用插件出错: {str(e)}") return False async def reload_plugin(self, plugin_name: str) -> bool: """重新加载插件""" try: return await self.plugin_manager.reload_plugin(plugin_name) except Exception as e: logger.log('WEBUI', f"重载插件出错: {str(e)}") return False # 创建全局实例 bot_bridge = BotBridge() ================================================ FILE: WebUI/config.py ================================================ import os import tomllib from pathlib import Path # 项目根目录 BASE_DIR = Path(__file__).resolve().parent.parent # 尝试读取主配置文件 try: with open(BASE_DIR / 'main_config.toml', 'rb') as f: toml_config = tomllib.load(f) WEBUI_CONFIG = toml_config.get('WebUI', {}) except (FileNotFoundError, tomllib.TOMLDecodeError): WEBUI_CONFIG = {} # Flask应用配置 SECRET_KEY = WEBUI_CONFIG.get("flask-secret-key") or os.environ.get('SECRET_KEY', 'HenryXiaoYang_XYBotV2') DEBUG = WEBUI_CONFIG.get("debug", False) # 会话配置 SESSION_TYPE = 'filesystem' SESSION_PERMANENT = True SESSION_USE_SIGNER = True SESSION_FILE_DIR = 'flask_session' PERMANENT_SESSION_LIFETIME = int(WEBUI_CONFIG.get('session-timeout', 30)) * 60 # 转换为秒 # 认证相关配置 ADMIN_USERNAME = WEBUI_CONFIG.get('admin-username', 'admin') ADMIN_PASSWORD = WEBUI_CONFIG.get('admin-password', 'admin123') # 日志配置 LOG_LEVEL = 'DEBUG' if DEBUG else 'INFO' LOG_FILE = BASE_DIR / 'logs' / 'webui.log' ================================================ FILE: WebUI/forms/__init__.py ================================================ ================================================ FILE: WebUI/forms/auth_forms.py ================================================ from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms.validators import DataRequired class LoginForm(FlaskForm): """登录表单""" username = StringField('用户名', validators=[DataRequired(message='请输入用户名')]) password = PasswordField('密码', validators=[DataRequired(message='请输入密码')]) remember_me = BooleanField('记住我') submit = SubmitField('登录') ================================================ FILE: WebUI/routes/__init__.py ================================================ def register_blueprints(app): """ 注册所有蓝图到Flask应用 参数: app: Flask应用实例 """ # 导入所有蓝图 from .auth import auth_bp from .overview import overview_bp from .logs import logs_bp from .config import config_bp from .plugin import plugin_bp from .tools import tools_bp from .file import file_bp from .bot import bot_bp from .explorer import explorer_bp from .about import about_bp # 注册蓝图 app.register_blueprint(auth_bp) app.register_blueprint(overview_bp) app.register_blueprint(logs_bp) app.register_blueprint(config_bp) app.register_blueprint(plugin_bp) app.register_blueprint(tools_bp) app.register_blueprint(file_bp) app.register_blueprint(bot_bp) app.register_blueprint(explorer_bp) app.register_blueprint(about_bp) ================================================ FILE: WebUI/routes/about.py ================================================ from flask import Blueprint, render_template from WebUI.utils.auth_utils import login_required about_bp = Blueprint('about', __name__) @about_bp.route('/about') @login_required def about(): """关于页面路由""" return render_template('about/index.html') ================================================ FILE: WebUI/routes/auth.py ================================================ from datetime import datetime, timedelta from flask import Blueprint, render_template, redirect, url_for, session, flash, current_app from WebUI.config import PERMANENT_SESSION_LIFETIME from WebUI.forms.auth_forms import LoginForm from WebUI.utils.auth_utils import verify_credentials, login_required # 创建认证蓝图 auth_bp = Blueprint('auth', __name__, url_prefix='/auth') @auth_bp.route('/login', methods=['GET', 'POST']) def login(): """登录页面""" # 已登录用户直接跳转到首页 if session.get('authenticated'): return redirect(url_for('overview.index')) form = LoginForm() # 表单提交处理 if form.validate_on_submit(): username = form.username.data password = form.password.data if verify_credentials(username, password): # 设置会话信息 session['authenticated'] = True session['username'] = username session['login_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') # 设置会话超时时间 session.permanent = True # 如果用户勾选"记住我",延长会话时间 if form.remember_me.data: # 延长会话时间为配置的两倍 current_app.permanent_session_lifetime = timedelta(seconds=PERMANENT_SESSION_LIFETIME * 2) # 获取请求来源并重定向 next_url = session.pop('redirect_url', None) if next_url: return redirect(next_url) return redirect(url_for('overview.index')) else: flash('用户名或密码错误', 'danger') # GET请求渲染登录页面 return render_template('auth/login.html', form=form, now=datetime.now()) @auth_bp.route('/logout') @login_required def logout(): """用户登出""" # 清除会话信息 session.clear() flash('您已成功退出登录', 'success') return redirect(url_for('auth.login')) ================================================ FILE: WebUI/routes/bot.py ================================================ from flask import Blueprint, jsonify from WebUI.services.bot_service import bot_service from WebUI.utils.auth_utils import login_required bot_bp = Blueprint('bot', __name__, url_prefix='/bot') @bot_bp.route('/api/start', methods=['POST']) @login_required def api_start_bot(): """启动机器人API""" success = bot_service.start_bot() return jsonify({ 'success': success, 'message': '机器人启动成功' if success else '机器人启动失败' }) @bot_bp.route('/api/stop', methods=['POST']) @login_required def api_stop_bot(): """停止机器人API""" success = bot_service.stop_bot() return jsonify({ 'success': success, 'message': '机器人停止成功' if success else '机器人停止失败' }) @bot_bp.route('/api/status', methods=['GET']) @login_required def api_get_bot_status(): """获取机器人状态API""" status = bot_service.get_status() return jsonify(status) ================================================ FILE: WebUI/routes/config.py ================================================ import traceback from flask import Blueprint, request, jsonify, render_template, current_app from WebUI.services.config_service import config_service from WebUI.utils.auth_utils import login_required # 创建蓝图 config_bp = Blueprint('config', __name__, url_prefix='/config') @config_bp.route('/', methods=['GET']) @login_required def configs_page(): """设置管理页面""" current_app.logger.info("访问设置管理页面") return render_template('config/index.html') @config_bp.route('/api/config', methods=['GET']) @login_required def get_config(): """ 获取配置 返回: JSON: 配置数据 """ try: current_app.logger.info("开始获取所有配置") config = config_service.get_config() current_app.logger.info(f"成功获取配置,包含 {len(config)} 个配置节") return jsonify({ "code": 0, "msg": "成功", "data": config }) except Exception as e: error_detail = traceback.format_exc() current_app.logger.error(f"获取配置失败: {str(e)}") current_app.logger.error(f"异常详情: {error_detail}") return jsonify({ "code": 500, "msg": f"获取配置失败: {str(e)}", "error_detail": error_detail }) @config_bp.route('/api/schema', methods=['GET']) @login_required def get_schema(): """ 获取表单架构 返回: JSON: 表单架构 """ try: current_app.logger.info("开始获取表单架构") schema = config_service.get_form_schema() current_app.logger.info(f"成功获取表单架构,包含 {len(schema)} 个配置节") return jsonify({ "code": 0, "msg": "成功", "data": schema }) except Exception as e: error_detail = traceback.format_exc() current_app.logger.error(f"获取表单架构失败: {str(e)}") current_app.logger.error(f"异常详情: {error_detail}") return jsonify({ "code": 500, "msg": f"获取表单架构失败: {str(e)}", "error_detail": error_detail }) @config_bp.route('/api/schemas', methods=['GET']) @login_required def get_schemas(): """ 获取表单架构(别名) 返回: JSON: 表单架构 """ current_app.logger.info("转发请求到 /api/schema") # 复用get_schema函数的逻辑 return get_schema() @config_bp.route('/api/config', methods=['POST']) @login_required def save_config(): """ 保存配置 请求体: JSON: 配置数据 返回: JSON: 操作结果 """ try: current_app.logger.info("开始保存所有配置") # 获取请求体中的JSON数据 config_data = request.json current_app.logger.info(f"接收到配置数据: {config_data}") if not config_data: current_app.logger.warning("保存配置失败: 请求数据为空") return jsonify({ "code": 400, "msg": "请求数据为空", "data": None }) # 验证配置 current_app.logger.info("验证配置数据") valid, errors = config_service.validate_config(config_data) if not valid: current_app.logger.warning(f"配置验证失败: {errors}") return jsonify({ "code": 400, "msg": "配置验证失败", "data": errors }) # 保存配置 current_app.logger.info("开始保存配置到文件") result = config_service.save_config(config_data) if result: current_app.logger.info("配置保存成功") return jsonify({ "code": 0, "msg": "配置保存成功", "data": None }) else: current_app.logger.warning("配置保存失败: 保存操作返回失败") return jsonify({ "code": 500, "msg": "配置保存失败", "data": None }) except Exception as e: error_detail = traceback.format_exc() current_app.logger.error(f"保存配置失败: {str(e)}") current_app.logger.error(f"异常详情: {error_detail}") return jsonify({ "code": 500, "msg": f"保存配置失败: {str(e)}", "error_detail": error_detail }) @config_bp.route('/api/config/', methods=['POST']) @login_required def save_specific_config(config_name: str): """ 保存特定配置 参数: config_name (str): 配置名称 请求体: JSON: 配置数据 返回: JSON: 操作结果 """ try: current_app.logger.info(f"开始保存特定配置节: {config_name}") # 获取请求体中的JSON数据 config_data = request.json current_app.logger.info(f"接收到配置数据: {config_data}") if not config_data: current_app.logger.warning(f"保存配置节 {config_name} 失败: 请求数据为空") return jsonify({ "code": 400, "msg": "请求数据为空", "data": None }) # 创建包含特定配置的完整配置数据结构 full_config = {config_name: config_data} # 验证配置 current_app.logger.info(f"验证配置节 {config_name} 数据") valid, errors = config_service.validate_config(full_config) if not valid: current_app.logger.warning(f"配置节 {config_name} 验证失败: {errors}") return jsonify({ "code": 400, "msg": "配置验证失败", "data": errors }) # 保存配置 current_app.logger.info(f"开始保存配置节 {config_name} 到文件") result = config_service.save_config(full_config) if result: current_app.logger.info(f"配置节 {config_name} 保存成功") return jsonify({ "code": 0, "msg": "配置保存成功", "data": None }) else: current_app.logger.warning(f"配置节 {config_name} 保存失败: 保存操作返回失败") return jsonify({ "code": 500, "msg": "配置保存失败", "data": None }) except Exception as e: error_detail = traceback.format_exc() current_app.logger.error(f"保存配置节 {config_name} 失败: {str(e)}") current_app.logger.error(f"异常详情: {error_detail}") return jsonify({ "code": 500, "msg": f"保存配置失败: {str(e)}", "error_detail": error_detail }) ================================================ FILE: WebUI/routes/explorer.py ================================================ from flask import Blueprint, render_template, request from WebUI.utils.auth_utils import login_required explorer_bp = Blueprint('explorer', __name__, url_prefix='/explorer') @explorer_bp.route('/') @login_required # 暂时注释掉 @login_required def index(): """文件浏览器主页""" path = request.args.get('path', '') return render_template('explorer/index.html', page_title='文件浏览器', initial_path=path) @explorer_bp.route('/view/') @login_required # 暂时注释掉 @login_required def view_file(file_path): """查看文件内容""" return render_template('explorer/view.html', file_path=file_path, page_title='文件查看') ================================================ FILE: WebUI/routes/file.py ================================================ from pathlib import Path from flask import Blueprint, render_template, jsonify, request from loguru import logger from werkzeug.exceptions import BadRequest from WebUI.services.file_service import file_service, ROOT_DIR from WebUI.utils.auth_utils import login_required file_bp = Blueprint('file', __name__, url_prefix='/file') def normalize_path(rel_path: str) -> Path: """安全路径标准化方法(增加日志目录例外)""" try: # 允许直接访问日志目录 if rel_path.strip().lower() == 'logs': log_path = ROOT_DIR / 'logs' if log_path.exists(): return log_path # 空路径处理 if not rel_path.strip(): return ROOT_DIR # 路径分解和清理 components = [] for part in rel_path.split('/'): part = part.strip() if not part or part == '.': continue if part == '..': if components: components.pop() continue components.append(part) safe_path = ROOT_DIR.joinpath(*components) resolved_path = safe_path.resolve() # 最终安全性检查 if not resolved_path.is_relative_to(ROOT_DIR.resolve()): raise BadRequest("非法路径访问") return resolved_path except Exception as e: logger.log('WEBUI', f"路径标准化错误: {str(e)}") raise BadRequest("路径处理错误") @file_bp.route('/api/list') @login_required def api_list_files(): """获取目录文件列表API""" try: raw_path = request.args.get('path', '') # 对于logs、plugins等特殊目录,提供额外处理 if raw_path.startswith('plugins/') or raw_path == 'plugins': # 确保plugins目录存在 plugins_dir = ROOT_DIR / 'plugins' if not plugins_dir.exists(): return jsonify({'error': '插件目录不存在'}), 404 # 如果是具体的插件目录 if raw_path != 'plugins': plugin_path = ROOT_DIR / raw_path if not plugin_path.exists(): return jsonify({'error': '指定的插件不存在'}), 404 if not plugin_path.is_dir(): return jsonify({'error': '不是目录'}), 400 target_path = ROOT_DIR / raw_path elif raw_path == 'logs' or raw_path.startswith('logs/'): # 日志目录特殊处理 logs_dir = ROOT_DIR / 'logs' if not logs_dir.exists(): return jsonify({'error': '日志目录不存在'}), 404 target_path = ROOT_DIR / raw_path else: # 常规路径处理 target_path = normalize_path(raw_path) if not target_path.exists(): return jsonify({ 'path': raw_path, 'files': [], 'error': '路径不存在' }), 200 # 返回200但提供错误消息 if not target_path.is_dir(): return jsonify({ 'path': raw_path, 'files': [], 'error': '不是目录' }), 200 files = file_service.list_directory(str(target_path.relative_to(ROOT_DIR))) return jsonify({ 'path': raw_path, 'files': files }) except Exception as e: logger.log('WEBUI', f"文件列表API错误: {str(e)}") return jsonify({ 'path': raw_path or '', 'files': [], 'error': str(e) }), 200 # 返回200但提供错误消息 def is_safe_path(path: str) -> bool: """检查路径是否安全,不包含危险组件""" return '../' not in path and not path.startswith('/') @file_bp.route('/api/content') @login_required def api_file_content(): """获取文件内容API""" try: # 获取请求参数 rel_path = request.args.get('path', '') start_line = int(request.args.get('start', 0)) max_lines = int(request.args.get('max', 1000)) # 使用normalize_path而不是is_safe_path try: # 特殊处理日志文件 if rel_path.startswith('logs/'): file_path = ROOT_DIR / rel_path else: # 使用normalize_path而不是is_safe_path file_path = normalize_path(rel_path) if not file_path.exists(): return jsonify({ 'content': [], 'info': { 'error': '文件不存在', 'size': 0, 'start_line': start_line, 'total_lines': 0 } }) # 获取文件内容 content, info = file_service.get_file_content( str(file_path.relative_to(ROOT_DIR)), start_line, max_lines ) return jsonify({ 'content': content, 'info': info }) except Exception as e: logger.log('WEBUI', f"读取文件失败: {str(e)}") return jsonify({ 'content': [], 'info': { 'error': f'读取文件失败: {str(e)}', 'path': rel_path, 'size': 0, 'start_line': start_line, 'total_lines': 0 } }) except Exception as e: logger.log('WEBUI', f"处理文件内容请求失败: {str(e)}") return jsonify({ 'content': [], 'info': { 'error': f'处理请求失败: {str(e)}' } }) @file_bp.route('/api/search') @login_required def api_search_in_file(): """在文件中搜索内容API""" try: path = request.args.get('path', '') query = request.args.get('query', '') if not path or not query: return jsonify({'error': '路径和查询参数不能为空'}), 400 results = file_service.search_in_file(path, query) return jsonify(results) except BadRequest as e: logger.log('WEBUI', f"搜索文件参数错误: {str(e)}") return jsonify({'error': str(e)}), 400 except Exception as e: logger.log('WEBUI', f"搜索文件失败: {str(e)}") return jsonify({'error': str(e)}), 500 @file_bp.route('/api/save', methods=['POST']) @login_required def api_save_file(): """保存文件内容API""" try: data = request.get_json() if not data: return jsonify({'error': '请求数据不能为空'}), 400 path = data.get('path', '') content = data.get('content', '') if not path: return jsonify({'error': '文件路径不能为空'}), 400 # 检查路径安全性 if not is_safe_path(path): logger.log('WEBUI', f"尝试访问不安全路径: {path}") return jsonify({'error': '路径不安全'}), 403 result = file_service.save_file_content(path, content) if result: logger.log('WEBUI', f"文件保存成功: {path}") return jsonify({'success': True, 'message': '文件保存成功'}) else: logger.log('WEBUI', f"文件保存失败: {path}") return jsonify({'success': False, 'error': '文件保存失败'}), 500 except BadRequest as e: logger.log('WEBUI', f"保存文件请求错误: {str(e)}") return jsonify({'error': str(e)}), 400 except Exception as e: logger.log('WEBUI', f"保存文件异常: {str(e)}") return jsonify({'error': str(e)}), 500 @file_bp.route('/view/') @login_required def view_file(file_path): """通用文件查看页面""" logger.log('WEBUI', f"访问文件查看页面: {file_path}") context = { 'page_title': '文件查看', 'file_path': file_path } return render_template('file/view.html', **context) ================================================ FILE: WebUI/routes/logs.py ================================================ from flask import Blueprint, render_template from WebUI.utils.auth_utils import login_required # 创建日志管理蓝图 logs_bp = Blueprint('logs', __name__, url_prefix='/logs') @logs_bp.route('/') @login_required def index(): """日志管理首页""" return render_template('logs/index.html', page_title='日志管理', directory='logs') ================================================ FILE: WebUI/routes/overview.py ================================================ from flask import Blueprint, render_template, jsonify import asyncio from WebUI.common.bot_bridge import bot_bridge from WebUI.services.bot_service import bot_service from WebUI.services.data_service import data_service from WebUI.utils.auth_utils import login_required # 创建概览蓝图 overview_bp = Blueprint('overview', __name__, url_prefix='/overview') @overview_bp.route('/') @login_required def index(): """概览页面首页""" # 获取初始数据 bot_status = bot_service.get_status() metrics = data_service.get_metrics() context = { 'page_title': '概览', 'bot_status': bot_status, 'metrics': metrics, 'status': 'running' if bot_status.get('running', False) else 'stopped' } return render_template('overview/index.html', **context) @overview_bp.route('/api/status') @login_required def api_status(): """获取机器人状态API""" try: # 获取最新的数据 data_service._refresh_cache_data() # 确保数据是最新的 # 获取所有数据 context = bot_bridge.get_profile() metrics = data_service.get_metrics() status = bot_service.get_status() # 确保所有数据是可JSON序列化的 for k, v in list(metrics.items()): if isinstance(v, (asyncio.Task, asyncio.Future)): try: # 尝试获取Task的结果 loop = asyncio.get_event_loop() if v._state == 'PENDING': metrics[k] = 0 # 如果任务仍在等待,使用默认值 else: metrics[k] = str(v) # 安全转换为字符串 except Exception: metrics[k] = 0 # 出错时使用默认值 # 确保所有值都是基本数据类型 for k, v in list(context.items()): if not isinstance(v, (str, int, float, bool, type(None))): context[k] = str(v) # 更新上下文 context.update(metrics) context.update(status) return jsonify(context) except Exception as e: # 返回错误信息 return jsonify({ 'error': str(e), 'status': 'error' }) ================================================ FILE: WebUI/routes/plugin.py ================================================ from flask import Blueprint, request, jsonify, render_template from loguru import logger from WebUI.services.plugin_service import plugin_service from WebUI.utils.auth_utils import login_required # 创建蓝图 plugin_bp = Blueprint('plugin', __name__, url_prefix='/plugin') @plugin_bp.route('/', methods=['GET']) @login_required def plugin_page(): """插件管理页面""" return render_template('plugin/index.html') @plugin_bp.route('/api/list', methods=['GET']) @login_required def get_plugins(): """ 获取所有插件列表 返回: JSON: 所有插件信息列表 """ try: plugins = plugin_service.get_all_plugins() logger.log("WEBUI", f"成功获取到 {len(plugins)} 个插件") return jsonify({ "code": 0, "msg": "成功", "data": plugins }) except Exception as e: logger.log("WEBUI", f"获取插件列表失败: {str(e)}") return jsonify({ "code": 500, "msg": f"获取插件列表失败: {str(e)}", "data": [] }) @plugin_bp.route('/api/detail/', methods=['GET']) @login_required def get_plugin_detail(plugin_name: str): """ 获取插件详情 参数: plugin_name (str): 插件ID 返回: JSON: 插件详细信息 """ plugin = plugin_service.get_plugin_details(plugin_name) if not plugin: return jsonify({ "code": 404, "msg": "插件不存在", "data": None }) return jsonify({ "code": 0, "msg": "成功", "data": plugin }) @plugin_bp.route('/api/enable/', methods=['POST']) @login_required def enable_plugin(plugin_name: str): """ 启用插件 参数: plugin_name (str): 插件ID 返回: JSON: 操作结果 """ try: # 使用run_async执行异步操作,而不是直接用asyncio.run result = plugin_service.run_async(plugin_service.enable_plugin(plugin_name)) if result: return jsonify({ "code": 0, "msg": "插件启用成功", "data": None }) else: return jsonify({ "code": 500, "msg": "插件启用失败", "data": None }) except Exception as e: logger.log("WEBUI", f"启用插件失败: {str(e)}") return jsonify({ "code": 500, "msg": f"启用插件失败: {str(e)}", "data": None }) @plugin_bp.route('/api/disable/', methods=['POST']) @login_required def disable_plugin(plugin_name: str): """ 禁用插件 参数: plugin_name (str): 插件ID 返回: JSON: 操作结果 """ try: # 使用run_async执行异步操作 result = plugin_service.run_async(plugin_service.disable_plugin(plugin_name)) if result: return jsonify({ "code": 0, "msg": "插件禁用成功", "data": None }) else: return jsonify({ "code": 500, "msg": "插件禁用失败", "data": None }) except Exception as e: logger.log("WEBUI", f"禁用插件失败: {str(e)}") return jsonify({ "code": 500, "msg": f"禁用插件失败: {str(e)}", "data": None }) @plugin_bp.route('/api/reload/', methods=['POST']) @login_required def reload_plugin(plugin_name: str): """ 重新加载插件 参数: plugin_name (str): 插件ID 返回: JSON: 操作结果 """ try: # 使用run_async执行异步操作 result = plugin_service.run_async(plugin_service.reload_plugin(plugin_name)) if result: return jsonify({ "code": 0, "msg": "插件重新加载成功", "data": None }) else: return jsonify({ "code": 500, "msg": "插件重新加载失败", "data": None }) except Exception as e: logger.log("WEBUI", f"重载插件失败: {str(e)}") return jsonify({ "code": 500, "msg": f"重载插件失败: {str(e)}", "data": None }) @plugin_bp.route('/api/config//list', methods=['GET']) @login_required def pluginl_list_files(plugin_name: str): """ 获取插件配置 参数: plugin_name (str): 插件ID 返回: JSON: 插件配置 """ root_key = request.args.get('root', 'logs') @plugin_bp.route('/api/config/', methods=['POST']) @login_required def save_plugin_config(plugin_name: str): """ 保存插件配置 参数: plugin_name (str): 插件ID 请求体: JSON: 插件配置 返回: JSON: 操作结果 """ # 获取请求体中的JSON数据 config_data = request.json if not config_data: return jsonify({ "code": 400, "msg": "请求数据为空", "data": None }) # 保存配置 result = plugin_service.save_plugin_config(plugin_name, config_data) if result: # 重载插件以应用新配置 try: logger.info(f"正在重载插件 {plugin_name} 以应用新配置...") reload_result = plugin_service.run_async(plugin_service.reload_plugin(plugin_name)) if reload_result: logger.info(f"插件 {plugin_name} 重载成功") else: logger.warning(f"插件 {plugin_name} 重载失败") return jsonify({ "code": 0, "msg": "配置保存成功", "data": None }) except Exception as e: logger.error(f"插件重载失败: {str(e)}") return jsonify({ "code": 0, "msg": "配置已保存,但插件重载失败", "data": None }) else: return jsonify({ "code": 500, "msg": "配置保存失败", "data": None }) ================================================ FILE: WebUI/routes/tools.py ================================================ from flask import Blueprint, render_template, jsonify, current_app from WebUI.services.tool_service import execute_tool, get_tools_list from WebUI.utils.auth_utils import login_required # 创建工具箱蓝图 tools_bp = Blueprint('tools', __name__, url_prefix='/tools') @tools_bp.route('/') @login_required def index(): """工具箱页面首页""" context = { 'page_title': '工具箱', } return render_template('tools/index.html', **context) @tools_bp.route('/api/list') @login_required def list_tools(): """获取工具列表""" try: tools = get_tools_list() return jsonify({ 'code': 0, 'msg': '获取成功', 'data': tools }) except Exception as e: current_app.logger.error(f"获取工具列表失败: {str(e)}") return jsonify({ 'code': 1, 'msg': f'获取工具列表失败: {str(e)}' }) @tools_bp.route('/api/execute/', methods=['POST']) @login_required def execute_tool_api(tool_id): """执行工具""" try: # 执行工具 result = execute_tool(tool_id) return jsonify({ 'code': 0, 'msg': '执行成功', 'data': result }) except Exception as e: error_msg = f"执行工具 {tool_id} 失败: {str(e)}" current_app.logger.error(error_msg) return jsonify({ 'code': 1, 'msg': error_msg }) ================================================ FILE: WebUI/services/bot_service.py ================================================ import asyncio import os import sys import threading import time import traceback from pathlib import Path from typing import Optional, Dict, Any, Union from loguru import logger from WebUI.common.bot_bridge import bot_bridge from WebUI.utils.singleton import Singleton # 项目根目录路径 ROOT_DIR = Path(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) # 导入机器人主函数 sys.path.append(str(ROOT_DIR)) from bot import run_bot # 线程本地存储,用于存储每个线程的事件循环 thread_local = threading.local() def get_or_create_eventloop() -> asyncio.AbstractEventLoop: try: # 尝试获取当前线程的事件循环 loop = asyncio.get_event_loop() # 检查事件循环是否已关闭,已关闭则创建新循环 if loop.is_closed(): logger.log('WEBUI', f"检测到线程 {threading.current_thread().name} 的事件循环已关闭,创建新循环") loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) except RuntimeError: # 如果当前线程没有事件循环,则创建一个新的 loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) logger.log('WEBUI', f"为线程 {threading.current_thread().name} 创建了新的事件循环") return loop class BotService(metaclass=Singleton): def __init__(self): """初始化机器人控制服务 设置内部状态变量,用于跟踪机器人运行状态。 """ self._task: Optional[Union[asyncio.Task, asyncio.Future]] = None self._start_time: float = 0 self._loop: Optional[asyncio.AbstractEventLoop] = None self._bot_thread: Optional[threading.Thread] = None def start_bot(self) -> bool: """启动机器人 创建并运行机器人主协程,根据当前事件循环状态选择合适的启动方式。 如果事件循环未运行,会在新线程中启动事件循环。 Returns: bool: 启动是否成功 """ # 如果机器人已在运行,直接返回 if self.is_running(): logger.log('WEBUI', "机器人已经在运行中,无需重复启动") return True try: # 获取或创建事件循环 loop = get_or_create_eventloop() self._loop = loop logger.log('WEBUI', "准备启动机器人...") # 在事件循环中创建并运行任务 if loop.is_running(): # 如果循环已经在运行,使用asyncio.run_coroutine_threadsafe logger.log('WEBUI', "在现有事件循环中启动机器人") future = asyncio.run_coroutine_threadsafe(run_bot(), loop) self._task = future else: # 如果循环未运行,在新线程中运行事件循环 logger.log('WEBUI', "创建新的事件循环来启动机器人") self._task = loop.create_task(run_bot()) # 在单独的线程中运行事件循环 def run_loop(): try: logger.log('WEBUI', "事件循环开始运行") loop.run_forever() except Exception as e: logger.log('WEBUI', f"事件循环异常: {str(e)}") logger.log('WEBUI', traceback.format_exc()) finally: logger.log('WEBUI', "事件循环已关闭") self._bot_thread = threading.Thread(target=run_loop, daemon=True) self._bot_thread.start() logger.log('WEBUI', f"已在后台线程启动事件循环 (线程ID: {self._bot_thread.ident})") self._start_time = time.time() bot_bridge.start_time = self._start_time bot_bridge.is_running = True logger.log('WEBUI', "机器人启动成功") return True except Exception as e: logger.log('WEBUI', f"启动机器人失败: {str(e)}") logger.log('WEBUI', traceback.format_exc()) return False def stop_bot(self) -> bool: """停止机器人 取消正在运行的机器人任务,并根据需要停止事件循环。 清理相关资源并重置状态。 Returns: bool: 停止是否成功 """ if not self.is_running(): logger.log('WEBUI', "机器人未在运行,无需停止") return True try: logger.log('WEBUI', "开始停止机器人...") # 取消异步任务 if self._task: if isinstance(self._task, asyncio.Task) and not self._task.done(): logger.log('WEBUI', "取消机器人任务") self._task.cancel() elif hasattr(self._task, 'cancel'): logger.log('WEBUI', "取消机器人Future") self._task.cancel() # 停止事件循环 if self._loop and not self._loop.is_closed() and self._loop.is_running(): logger.log('WEBUI', "停止事件循环") self._loop.call_soon_threadsafe(self._loop.stop) # 重置状态 self._task = None self._start_time = 0 self._bot_thread = None self._loop = None bot_bridge.start_time = 0 bot_bridge.is_running = False logger.log('WEBUI', "机器人已成功停止") return True except Exception as e: logger.log('WEBUI', f"停止机器人失败: {str(e)}") logger.log('WEBUI', traceback.format_exc()) return False def is_running(self) -> bool: """检查机器人是否正在运行 通过检查异步任务的状态确定机器人是否在运行。 如果任务已完成或出错,会重置状态。 Returns: bool: 机器人是否正在运行 """ # 检查异步任务是否正在运行 if self._task: if isinstance(self._task, asyncio.Task) and not self._task.done(): return True elif hasattr(self._task, 'done') and not self._task.done(): return True # 如果任务已完成或出错,重置状态 if self._task is not None: logger.log('WEBUI', "检测到机器人任务已完成或出错,重置状态") self._task = None self._start_time = 0 self._loop = None self._bot_thread = None bot_bridge.start_time = 0 bot_bridge.is_running = False return False def get_status(self) -> Dict[str, Any]: """获取机器人状态信息 收集当前运行状态、进程ID和启动时间等信息。 Returns: Dict[str, Any]: 包含状态信息的字典 """ running = self.is_running() status = { 'running': running, 'pid': os.getpid(), 'start_time': time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self._start_time)) if running else 0, } return status # 创建机器人控制服务实例 bot_service = BotService() ================================================ FILE: WebUI/services/config_service.py ================================================ import os import re import sys import traceback from pathlib import Path from typing import Dict, Any, List, Tuple, Optional, Union import tomlkit from loguru import logger from WebUI.utils.singleton import Singleton # 确保可以导入根目录模块 ROOT_DIR = Path(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) sys.path.insert(0, str(ROOT_DIR)) class ConfigService(metaclass=Singleton): """配置服务类,提供配置文件管理功能 负责读取、保存和验证XYBotV2系统配置,使用TOML格式存储配置信息。 支持字段验证、默认值和表单模式生成,方便Web界面的配置编辑。 """ def __init__(self): """初始化配置服务 设置配置文件路径,定义默认配置结构、字段选项和验证规则。 """ self.config_path = ROOT_DIR / "main_config.toml" # 默认配置结构 self.default_config = { "WechatAPIServer": { "port": 9000, "mode": "release", "redis-host": "127.0.0.1", "redis-port": 6379, "redis-password": "", "redis-db": 0 }, "XYBot": { "version": "v1.0.0", "ignore-protection": False, "XYBotDB-url": "sqlite:///database/xybot.db", "msgDB-url": "sqlite+aiosqlite:///database/message.db", "keyvalDB-url": "sqlite+aiosqlite:///database/keyval.db", "admins": ["admin-wxid"], "disabled-plugins": ["ExamplePlugin"], "timezone": "Asia/Shanghai", "ignore-mode": "None" }, "WebUI": { "admin-username": "admin", "admin-password": "admin123", "session-timeout": 30 } } # 字段选项(用于表单下拉选择) self.field_options = { "WechatAPIServer.mode": ["release", "debug"], "XYBot.ignore-mode": ["None", "Whitelist", "Blacklist"] } # 字段验证规则 self.field_validators = { "WechatAPIServer.port": lambda v: isinstance(v, int) and 1 <= v <= 65535, "WechatAPIServer.redis-port": lambda v: isinstance(v, int) and 1 <= v <= 65535, "WebUI.session-timeout": lambda v: isinstance(v, int) and v > 0 } def get_config(self) -> Dict[str, Any]: """获取完整配置 从配置文件读取完整配置,如果文件不存在或读取失败则返回默认配置。 Returns: Dict[str, Any]: 配置数据字典 """ if not self.config_path.exists(): logger.log('WEBUI', f"配置文件不存在,将使用默认配置: {self.config_path}") return self.default_config try: with open(self.config_path, "r", encoding="utf-8") as f: return tomlkit.parse(f.read()) except Exception as e: logger.log('WEBUI', f"读取配置文件出错: {str(e)}") return self.default_config def get_toml_doc(self) -> Optional[Union[tomlkit.TOMLDocument, dict]]: """获取TOML文档对象,保留所有格式和注释 与get_config不同,此方法返回原始TOML文档对象,保留所有格式和注释。 Returns: Optional[Union[tomlkit.TOMLDocument, dict]]: TOML文档对象或None(如果读取失败) """ if not self.config_path.exists(): logger.log('WEBUI', f"TOML文档不存在: {self.config_path}") return None try: with open(self.config_path, "r", encoding="utf-8") as f: return tomlkit.parse(f.read()) except Exception as e: logger.log('WEBUI', f"读取TOML文档出错: {str(e)}") return None def extract_comments(self) -> Dict[str, str]: """从TOML文件中提取注释 解析TOML文件,提取每个配置字段的注释,用于生成表单时提供帮助文本。 Returns: Dict[str, str]: 字段路径到注释的映射,格式为 {"section.key": "注释内容"} """ comments = {} try: # 如果文件不存在,直接返回空字典 if not self.config_path.exists(): logger.log('WEBUI', "无法提取注释:配置文件不存在") return comments # 读取原始文件内容 with open(self.config_path, "r", encoding="utf-8") as f: content = f.read() # 按行解析并提取注释 lines = content.split('\n') current_section = "" standalone_comment_lines = [] # 记录独立注释行的行号 for i, line in enumerate(lines): line = line.strip() # 跳过空行 if not line: continue # 检查是否是节定义行 section_match = re.match(r'^\[([^\]]+)\]', line) if section_match: current_section = section_match.group(1) continue # 记录独立的注释行(不紧随在键值对后面的注释) if line.startswith('#') and ( i == 0 or not lines[i - 1].strip() or lines[i - 1].strip().startswith('#')): standalone_comment_lines.append(i) continue # 解析带注释的键值对(key = value # comment) kv_comment_match = re.match(r'^([^=]+)=([^#]*)#(.+)$', line) if kv_comment_match: key = kv_comment_match.group(1).strip() comment = kv_comment_match.group(3).strip() field_path = f"{current_section}.{key}" comments[field_path] = comment continue # 检查键值对后面没有注释的情况(查找前一行是否为注释) kv_match = re.match(r'^([^=]+)=(.*)$', line) if kv_match: key = kv_match.group(1).strip() # 寻找前一行是否是该字段的注释(且不是独立注释行) idx = i - 1 if (idx >= 0 and idx not in standalone_comment_lines and lines[idx].strip().startswith('#')): comment = lines[idx].strip()[1:].strip() field_path = f"{current_section}.{key}" comments[field_path] = comment logger.log('WEBUI', f"成功提取配置注释 {len(comments)} 条") return comments except Exception as e: logger.log('WEBUI', f"提取注释出错: {str(e)}") return {} def save_config(self, config: Dict[str, Any]) -> bool: """保存配置到文件 将配置保存到TOML文件,会尝试保留现有的注释和格式。 Args: config: 要保存的配置数据 Returns: bool: 是否成功保存 """ try: # 打印接收到的配置数据,用于调试 logger.log('WEBUI', f"接收到配置数据: {config}") # 修复已损坏的配置结构 - 处理嵌套结构变成的特殊情况 self._fix_nested_config_structure(config) # 如果配置文件已存在,先读取它以保留注释和格式 doc = tomlkit.document() if self.config_path.exists(): try: with open(self.config_path, "r", encoding="utf-8") as f: doc = tomlkit.parse(f.read()) except Exception as e: logger.log('WEBUI', f"读取现有配置文件失败,将创建新文件: {str(e)}") # 如果读取失败,创建新文档 doc = tomlkit.document() # 更新配置 for section_name, section_data in config.items(): if section_name not in doc: doc[section_name] = tomlkit.table() logger.log('WEBUI', f"创建新配置节: {section_name}") for key, value in section_data.items(): # 特殊处理列表类型,确保保持为列表类型而不是字符串 if isinstance(value, list): # 记录调试信息 logger.log('WEBUI', f"处理列表字段 {section_name}.{key},原始值: {value}") # 创建一个新的tomlkit数组对象 toml_array = tomlkit.array() # 遍历列表中每个元素,添加到tomlkit数组 for item in value: # 确保不会添加None if item is not None: # 去除可能的前后空格 if isinstance(item, str): item = item.strip() if not item: # 跳过空字符串 continue toml_array.append(item) logger.log('WEBUI', f" - 添加元素: {item} (类型: {type(item).__name__})") # 设置数组值 doc[section_name][key] = toml_array logger.log('WEBUI', f"完成列表字段 {section_name}.{key} 保存: {toml_array}") else: # 原始值,记录用于调试 orig_value = doc[section_name].get(key, None) if section_name in doc else None # 设置新值 doc[section_name][key] = value logger.log('WEBUI', f"设置字段 {section_name}.{key}: {value} (原值: {orig_value})") # 保存配置 with open(self.config_path, "w", encoding="utf-8") as f: toml_content = tomlkit.dumps(doc) logger.log('WEBUI', f"生成的TOML内容: \n{toml_content}") f.write(toml_content) logger.log('WEBUI', "配置已成功保存") return True except Exception as e: logger.log('WEBUI', f"保存配置文件出错: {str(e)}") logger.log('WEBUI', f"异常详情: {traceback.format_exc()}") return False def _fix_nested_config_structure(self, config: Dict[str, Any]) -> None: """ 修复配置结构中被错误保存的嵌套结构 特别处理那些应该是单一字段但被分解为嵌套结构的情况 例如:disabled-plugins 被错误保存为 [XYBot.disabled] 下的 plugins Args: config: 要修复的配置数据 """ try: # 特殊处理 disabled-plugins 的情况 if 'XYBot' in config: # 情况1: disabled-plugins 被错误地保存为嵌套结构 [XYBot.disabled] if 'disabled' in config['XYBot'] and isinstance(config['XYBot']['disabled'], dict): if 'plugins' in config['XYBot']['disabled']: # 将 XYBot.disabled.plugins 的值转移到 XYBot.disabled-plugins plugins_value = config['XYBot']['disabled']['plugins'] logger.log('WEBUI', f"修复嵌套结构: 将 XYBot.disabled.plugins 值 {plugins_value} 转移到 XYBot.disabled-plugins") config['XYBot']['disabled-plugins'] = plugins_value # 删除嵌套结构 del config['XYBot']['disabled'] # 情况2: disabled-plugins 字段存在,但不是数组类型 if 'disabled-plugins' in config['XYBot'] and not isinstance(config['XYBot']['disabled-plugins'], list): # 尝试将字符串转换为数组 plugin_value = config['XYBot']['disabled-plugins'] logger.log('WEBUI', f"修复 disabled-plugins 类型: 从 {type(plugin_value).__name__} 转换为数组") if isinstance(plugin_value, str): # 如果是空字符串,转换为空数组 if not plugin_value.strip(): config['XYBot']['disabled-plugins'] = [] # 如果是逗号分隔的字符串,拆分成数组 elif ',' in plugin_value: config['XYBot']['disabled-plugins'] = [item.strip() for item in plugin_value.split(',') if item.strip()] # 否则作为单个元素的数组 else: config['XYBot']['disabled-plugins'] = [plugin_value.strip()] # 清理可能的 undefined 字段 for section_name in list(config.keys()): if section_name == 'undefined': logger.log('WEBUI', f"删除无效配置节: {section_name}") del config[section_name] continue section_data = config[section_name] if isinstance(section_data, dict): for key in list(section_data.keys()): if key == 'undefined': logger.log('WEBUI', f"删除无效字段: {section_name}.{key}") del section_data[key] logger.log('WEBUI', "配置结构修复完成") except Exception as e: logger.log('WEBUI', f"修复配置结构时出错: {str(e)}") logger.log('WEBUI', f"异常详情: {traceback.format_exc()}") def get_form_schema(self) -> Dict[str, Any]: """获取表单架构,用于Web界面动态生成配置表单 根据当前配置和字段元数据生成符合前端需求的表单架构。 Returns: Dict[str, Any]: 表单架构,格式为 {section_name: {title, description, properties, propertyOrder}} """ # 获取当前配置 config = self.get_config() # 从文件中读取原始配置顺序 sections_order = [] fields_order = {} try: if self.config_path.exists(): with open(self.config_path, "r", encoding="utf-8") as f: content = f.read() # 按行解析配置文件,提取节和字段的顺序 lines = content.split('\n') current_section = None for line in lines: line = line.strip() if not line or line.startswith('#'): continue # 检查是否是节定义行 section_match = re.match(r'^\[([^\]]+)\]', line) if section_match: current_section = section_match.group(1) if current_section not in sections_order: sections_order.append(current_section) fields_order[current_section] = [] continue # 检查是否是键值对 kv_match = re.match(r'^([^=]+)=', line) if kv_match and current_section: field_name = kv_match.group(1).strip() if field_name not in fields_order[current_section]: fields_order[current_section].append(field_name) logger.log('WEBUI', f"提取到配置节顺序: {sections_order}") for section, fields in fields_order.items(): logger.log('WEBUI', f"配置节 {section} 的字段顺序: {fields}") except Exception as e: logger.log('WEBUI', f"提取配置顺序出错: {str(e)}") # 如果出错,使用默认顺序 sections_order = list(config.keys()) fields_order = {section: list(section_data.keys()) for section, section_data in config.items()} # 从TOML文件中提取注释 comments = self.extract_comments() # 创建符合前端预期的schema格式 schemas = {} # 要忽略的字段名 ignored_fields = ['undefined'] # 遍历配置节(按原始顺序) for section_name in sections_order: if section_name not in config: continue section_data = config[section_name] # 跳过无效的配置节 if section_name in ignored_fields: logger.log('WEBUI', f"跳过无效配置节: {section_name}") continue # 创建该配置节的schema section_schema = { "title": section_name, "description": "", # 可以为配置节添加描述信息 "properties": {}, "propertyOrder": fields_order.get(section_name, []) # 添加字段顺序信息 } # 遍历字段(按原始顺序) for field_name in fields_order.get(section_name, []): if field_name not in section_data: continue # 跳过无效字段 if field_name in ignored_fields: logger.log('WEBUI', f"跳过无效字段: {section_name}.{field_name}") continue field_value = section_data[field_name] field_path = f"{section_name}.{field_name}" field_type = self._get_field_type(field_value) # 从TOML注释中获取字段描述 field_description = comments.get(field_path, "") # 创建字段的schema field_schema = { "title": field_name, "type": field_type, "description": field_description, } # 添加选项(如果有) if self.field_options.get(field_path): field_schema["enum"] = self.field_options.get(field_path, []) # 如果是数组类型,添加items描述 if field_type == "array": # 确定数组元素类型 items_type = "string" # 默认为字符串类型 if field_value and len(field_value) > 0: items_type = self._get_field_type(field_value[0]) field_schema["items"] = {"type": items_type} # 将字段schema添加到配置节properties中 section_schema["properties"][field_name] = field_schema # 将配置节schema添加到总schema中 schemas[section_name] = section_schema # 添加配置节顺序信息到返回值 schemas["_meta"] = { "sectionsOrder": sections_order } logger.log('WEBUI', f"已生成配置表单架构,包含 {len(schemas) - 1} 个配置节") return schemas def validate_config(self, config: Dict[str, Any]) -> Tuple[bool, List[str]]: """验证配置是否符合规则 检查配置中的字段是否符合预定义的验证规则。 Args: config: 要验证的配置数据 Returns: Tuple[bool, List[str]]: (是否验证通过, 错误信息列表) """ errors = [] for section_name, section_data in config.items(): for field_name, field_value in section_data.items(): field_path = f"{section_name}.{field_name}" # 如果有验证器,则进行验证 if field_path in self.field_validators: validator = self.field_validators[field_path] if not validator(field_value): errors.append(f"字段 '{field_path}' 的值 '{field_value}' 无效") if errors: logger.log('WEBUI', f"配置验证失败,发现 {len(errors)} 个错误") for error in errors: logger.log('WEBUI', f"配置错误: {error}") else: logger.log('WEBUI', "配置验证通过") return len(errors) == 0, errors def _dict_to_toml(self, data: Dict[str, Any]) -> str: """将字典转换为TOML字符串 内部工具方法,将配置字典转换为格式化的TOML字符串。 Args: data: 要转换的字典数据 Returns: str: 格式化的TOML字符串 """ doc = tomlkit.document() for section_name, section_data in data.items(): table = tomlkit.table() for key, value in section_data.items(): table.add(key, value) doc.add(section_name, table) return tomlkit.dumps(doc) def _get_field_type(self, value: Any) -> str: """推断字段值的数据类型 根据字段值推断适合的JSON Schema类型名称。 Args: value: 字段值 Returns: str: 字段类型名称(如"boolean", "integer", "string"等) """ if isinstance(value, bool): return "boolean" elif isinstance(value, int): return "integer" elif isinstance(value, float): return "number" elif isinstance(value, list): return "array" elif isinstance(value, dict): return "object" elif isinstance(value, str): return "string" else: return "string" # 默认为字符串类型 def get_version(self) -> str: """获取XYBot版本号 从配置中提取XYBot的版本号。 Returns: str: 版本号,如果未找到则返回"未知" """ config = self.get_config() try: return config.get("XYBot", {}).get("version", "未知") except Exception as e: logger.log('WEBUI', f"获取版本号失败: {str(e)}") return "未知" # 创建配置服务实例 config_service = ConfigService() ================================================ FILE: WebUI/services/data_service.py ================================================ import asyncio import logging import os import time from pathlib import Path from WebUI.common.bot_bridge import bot_bridge from WebUI.services.bot_service import bot_service from WebUI.utils.async_to_sync import async_to_sync from WebUI.utils.singleton import Singleton # 项目根目录路径 ROOT_DIR = Path(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) # 日志目录 LOGS_DIR = ROOT_DIR / 'logs' # 日志文件路径 BOT_LOG_PATH = LOGS_DIR / 'xybot.log' # 设置日志记录器 logger = logging.getLogger(__name__) handler = logging.StreamHandler() formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') handler.setFormatter(formatter) logger.addHandler(handler) logger.setLevel(logging.INFO) class DataService(metaclass=Singleton): """数据服务类,提供机器人状态和统计数据""" def __init__(self): """初始化数据服务""" self._cache = {} self._last_update = 0 self._update_interval = 5 # 更新间隔(秒) self._last_user_sync_time = 0 # 上次同步用户数量的时间 # 确保异步初始化被执行 try: loop = asyncio.get_event_loop() except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(self._init_async()) # 日志文件位置追踪 self._log_last_position = 0 async def _init_async(self): """异步初始化""" self._log_last_position = await bot_bridge.get_log_position() def get_bot_status(self): """ 获取机器人当前运行状态 返回: dict: 包含状态信息的字典 """ # 检查缓存是否需要更新 return { 'running': bot_service.is_running(), 'status': 'running' if bot_service.is_running() else 'stopped', 'uptime': self._get_uptime(), 'message_count': self._cache.get('messages', 0), 'user_count': self._cache.get('users', 0) } def get_metrics(self): """ 获取机器人核心指标 """ # 先刷新缓存数据再返回 self._refresh_cache_data() return { 'messages': self._cache.get('messages', 0), 'users': self._cache.get('users', 0), 'uptime': self._get_uptime_formatted() } def _refresh_cache_data(self): """ 刷新缓存数据 """ current_time = time.time() # 如果距离上次更新时间超过了更新间隔,则更新缓存 if current_time - self._last_update > self._update_interval: try: # 获取事件循环 try: loop = asyncio.get_event_loop() except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) # 直接同步执行异步操作 if loop.is_running(): # 如果循环正在运行,使用Future同步等待结果 messages = asyncio.run_coroutine_threadsafe(bot_bridge.get_message_count(), loop).result(5) users = asyncio.run_coroutine_threadsafe(bot_bridge.get_user_count(), loop).result(5) else: # 否则直接执行到完成 messages = loop.run_until_complete(bot_bridge.get_message_count()) users = loop.run_until_complete(bot_bridge.get_user_count()) # 同步XYBotDB的真实用户数量到KeyvalDB (每10分钟执行一次) ten_minutes = 600 # 10分钟的秒数 if current_time - self._last_user_sync_time > ten_minutes: try: # 导入XYBotDB以获取真实用户数量 from database.XYBotDB import XYBotDB db = XYBotDB() real_users_count = db.get_users_count() # 只有当实际用户数大于当前计数时才更新 if real_users_count > users: if loop.is_running(): asyncio.run_coroutine_threadsafe( bot_bridge._db.set("bot:stats:user_count", str(real_users_count)), loop ).result(5) users = real_users_count else: loop.run_until_complete( bot_bridge._db.set("bot:stats:user_count", str(real_users_count)) ) users = real_users_count logger.info(f"已从XYBotDB同步用户数量: {real_users_count}") self._last_user_sync_time = current_time except Exception as e: logger.error(f"同步用户数量失败: {str(e)}") # 更新缓存 - 直接从bot_bridge获取start_time,不需要异步调用 self._cache['messages'] = messages self._cache['users'] = users self._cache['start_time'] = bot_bridge.start_time self._last_update = current_time logger.info(f"缓存数据已刷新: 消息数={messages}, 用户数={users}, 启动时间={bot_bridge.start_time}") except Exception as e: logger.error(f"刷新缓存数据失败: {str(e)}") # 使用默认值 self._cache.setdefault('messages', 0) self._cache.setdefault('users', 0) self._cache.setdefault('start_time', 0) def get_recent_logs(self, n=100): """ 获取最近的日志 """ logs = [] try: if BOT_LOG_PATH.exists(): with open(BOT_LOG_PATH, 'r', encoding='utf-8') as f: lines = f.readlines() logs = lines[-n:] if len(lines) > n else lines # 更新日志位置指针 self._log_last_position = os.path.getsize(BOT_LOG_PATH) self._save_log_position() except Exception as e: error_msg = f"读取日志文件出错: {str(e)}" logger.error(error_msg) logs = [error_msg] # 确保返回的是字符串列表,并移除每行末尾的换行符 return [line.strip() if isinstance(line, str) else str(line).strip() for line in logs] def get_new_logs(self): """ 获取新增的日志内容(增量更新) """ new_logs = [] try: if BOT_LOG_PATH.exists(): current_size = os.path.getsize(BOT_LOG_PATH) # 检查是否有新内容 if current_size > self._log_last_position: with open(BOT_LOG_PATH, 'r', encoding='utf-8') as f: # 移动到上次读取的位置 f.seek(self._log_last_position) # 读取新增内容 new_lines = f.readlines() new_logs = [line.strip() for line in new_lines] # 更新位置指针 self._log_last_position = current_size self._save_log_position() except Exception as e: error_msg = f"读取新增日志出错: {str(e)}" logger.error(error_msg) new_logs = [error_msg] # 确保返回的是字符串列表 return [line if isinstance(line, str) else str(line) for line in new_logs] @async_to_sync async def _save_log_position(self): """保存日志读取位置到数据库""" try: await bot_bridge.save_log_position(self._log_last_position) return True except Exception as e: logger.error(f"保存日志位置失败: {str(e)}") return False @async_to_sync async def _get_message_count(self): """ 获取接收消息数量 """ try: return await bot_bridge.get_message_count() except Exception as e: logger.error(f"获取消息计数失败: {str(e)}") return 0 @async_to_sync async def _get_user_count(self): """ 获取用户数量 """ try: return await bot_bridge.get_user_count() except Exception as e: logger.error(f"获取用户计数失败: {str(e)}") return 0 @async_to_sync async def _get_start_time(self): """ 获取机器人启动时间 返回: float: 启动时间戳 """ try: return await bot_bridge.get_start_time() except Exception as e: logger.error(f"获取启动时间失败: {str(e)}") return 0 def _get_uptime(self): """ 获取机器人运行时长(秒) 返回: int: 运行时长(秒) """ try: if not bot_service.is_running(): return 0 start_time = self._cache.get('start_time', 0) if start_time == 0: return 0 return int(time.time() - float(start_time)) except Exception as e: logger.error(f"计算运行时长失败: {str(e)}") return 0 def _get_uptime_formatted(self): """ 获取格式化的运行时长 返回: str: 格式化的运行时长 """ try: uptime_seconds = self._get_uptime() if uptime_seconds == 0: return "未运行" # 将秒转换为天、小时、分钟、秒 days, remainder = divmod(uptime_seconds, 86400) hours, remainder = divmod(remainder, 3600) minutes, seconds = divmod(remainder, 60) if days > 0: return f"{days}天 {hours}小时" elif hours > 0: return f"{hours}小时 {minutes}分钟" elif minutes > 0: return f"{minutes}分钟 {seconds}秒" else: return f"{seconds}秒" except Exception as e: logger.error(f"格式化运行时长失败: {str(e)}") return "未知" @async_to_sync async def increment_message_count(self, amount=1): """ 增加消息计数 参数: amount (int): 增加数量 返回: bool: 操作是否成功 """ try: return await bot_bridge.increment_message_count(amount) except Exception as e: logger.error(f"增加消息计数失败: {str(e)}") return False @async_to_sync async def increment_user_count(self, amount=1): """ 增加用户计数 参数: amount (int): 增加数量 返回: bool: 操作是否成功 """ try: return await bot_bridge.increment_user_count(amount) except Exception as e: logger.error(f"增加用户计数失败: {str(e)}") return False # 创建数据服务实例 data_service = DataService() ================================================ FILE: WebUI/services/file_service.py ================================================ import os import traceback from pathlib import Path from typing import List, Dict, Any, Tuple from loguru import logger from WebUI.utils.singleton import Singleton # 项目根目录路径 ROOT_DIR = Path(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) # 日志目录 LOGS_DIR = ROOT_DIR / 'logs' class SecurityError(Exception): """安全性验证错误,当路径访问超出允许范围时抛出""" pass class PathValidationError(Exception): """路径验证错误,当路径处理过程中出现问题时抛出""" pass class FileService(metaclass=Singleton): """文件服务类,提供文件系统操作功能 负责处理文件系统相关操作,包括读取目录内容、获取文件内容、 在文件中搜索和保存文件。所有路径操作都经过安全性验证, 以防止目录遍历攻击。 """ def __init__(self): """初始化文件服务 确保必要的目录结构存在,如日志目录。 """ # 确保日志目录存在 LOGS_DIR.mkdir(parents=True, exist_ok=True) def _validate_path(self, rel_path: str) -> Path: """验证并返回安全的文件路径 对输入的相对路径进行安全验证,确保访问不超出根目录范围, 防止目录遍历攻击和恶意访问。 Args: rel_path: 相对于根目录的文件路径 Returns: Path: 验证后的安全绝对路径 Raises: SecurityError: 当路径尝试访问根目录之外的位置时 PathValidationError: 当路径处理过程中出现其他错误时 """ try: # 规范化路径处理 clean_path = rel_path.strip('/') # 去除首尾斜杠 if not clean_path: # 处理根目录情况 return ROOT_DIR # 分解路径组件并过滤危险字符 path_components = [p for p in clean_path.split('/') if p not in ('', '.', '..')] # 重建安全路径 safe_path = ROOT_DIR.joinpath(*path_components) resolved_path = safe_path.resolve() # 二次验证路径安全性 if not resolved_path.is_relative_to(ROOT_DIR): logger.log('WEBUI', f"安全警告:路径越界访问尝试: {rel_path} -> {resolved_path}") raise SecurityError("路径越界访问尝试") return resolved_path except SecurityError: # 直接重新抛出安全错误 raise except Exception as e: logger.log('WEBUI', f"路径验证失败: {rel_path}, 错误: {str(e)}") raise PathValidationError(f"路径验证失败: {str(e)}") def list_directory(self, rel_path: str = '') -> List[Dict[str, Any]]: """列出指定目录中的内容 获取指定目录中的文件和子目录列表,并返回其元数据信息。 结果按照目录优先、名称字母顺序排序。 Args: rel_path: 相对于根目录的目录路径,默认为根目录 Returns: List[Dict[str, Any]]: 目录内容列表,每项包含名称、路径、类型等信息 """ try: target_dir = self._validate_path(rel_path) if not target_dir.exists(): logger.log('WEBUI', f"目录不存在: {target_dir}") raise FileNotFoundError(f"目录不存在: {target_dir}") if not target_dir.is_dir(): logger.log('WEBUI', f"路径不是目录: {target_dir}") raise NotADirectoryError(f"路径不是目录: {target_dir}") items = [] for path in target_dir.iterdir(): try: # 过滤隐藏文件(以.开头) if path.name.startswith('.'): continue stat = path.stat() items.append({ 'name': path.name, 'path': str(path.relative_to(ROOT_DIR)), 'is_dir': path.is_dir(), 'size': stat.st_size, 'modified': stat.st_mtime, 'created': stat.st_ctime, 'permissions': stat.st_mode }) except Exception as e: logger.log('WEBUI', f"处理目录项时出错: {path.name}, 错误: {str(e)}") continue # 跳过处理失败的项 # 排序:目录在前,按名称排序 sorted_items = sorted(items, key=lambda x: (not x['is_dir'], x['name'].lower())) return sorted_items except (SecurityError, PathValidationError, FileNotFoundError, NotADirectoryError) as e: # 这些是预期的异常,记录后返回空列表 logger.log('WEBUI', f"目录列表错误: {str(e)}") return [] except Exception as e: # 未预期的异常,记录详细信息 logger.log('WEBUI', f"目录列表未知错误: {str(e)}") logger.log('WEBUI', traceback.format_exc()) return [] def get_file_content(self, rel_path: str = '', start_line: int = 0, max_lines: int = 1000) -> Tuple[List[str], Dict[str, Any]]: """获取文件内容 读取指定文件的内容,支持指定起始行和最大行数,适用于分页读取大文件。 Args: rel_path: 相对于根目录的文件路径 start_line: 起始行号(从0开始) max_lines: 最大行数 Returns: Tuple[List[str], Dict[str, Any]]: (文件内容行列表, 文件信息字典) """ try: file_path = self._validate_path(rel_path) if not file_path.exists() or not file_path.is_file(): logger.log('WEBUI', f"文件不存在或不是常规文件: {rel_path}") return [], {'error': '文件不存在'} file_info = { 'name': file_path.name, 'path': str(file_path.relative_to(ROOT_DIR)), 'size': file_path.stat().st_size, 'modified': file_path.stat().st_mtime, 'created': file_path.stat().st_ctime, 'total_lines': 0, 'start_line': start_line, 'end_line': 0 } # 读取文件内容 with open(file_path, 'r', encoding='utf-8', errors='replace') as f: # 如果起始行为0,直接读取指定行数 if start_line == 0: lines = [] for i, line in enumerate(f): if i >= max_lines: break lines.append(line.rstrip('\n')) file_info['total_lines'] = i + 1 if i < max_lines else i + 1 + 1 # +1表示还有更多行 file_info['end_line'] = min(start_line + len(lines) - 1, file_info['total_lines'] - 1) logger.log('WEBUI', f"读取文件 {rel_path} 内容, 从第 {start_line} 行起,共 {len(lines)} 行") return lines, file_info # 否则,先跳过前面的行 for i, _ in enumerate(f): if i >= start_line - 1: break if i < start_line - 1: # 起始行超出文件行数 file_info['total_lines'] = i + 1 file_info['end_line'] = i logger.log('WEBUI', f"请求的起始行 {start_line} 超出文件 {rel_path} 的行数 {i + 1}") return [], file_info # 然后读取指定行数 lines = [] for j in range(max_lines): line = f.readline() if not line: break lines.append(line.rstrip('\n')) # 继续读取剩余行数,计算总行数 remaining_count = 0 while f.readline(): remaining_count += 1 if remaining_count > 1000: # 设置一个合理的限制,避免处理过大的文件 break file_info['total_lines'] = start_line + len(lines) + remaining_count file_info['end_line'] = min(start_line + len(lines) - 1, file_info['total_lines'] - 1) logger.log('WEBUI', f"读取文件 {rel_path} 内容, 从第 {start_line} 行起,共 {len(lines)} 行,总行数约 {file_info['total_lines']}") return lines, file_info except (SecurityError, PathValidationError) as e: # 安全相关错误 logger.log('WEBUI', f"读取文件内容安全错误: {str(e)}") return [], {'error': f'安全错误: {str(e)}'} except UnicodeDecodeError as e: # 编码错误,可能是二进制文件 logger.log('WEBUI', f"文件编码错误: {rel_path}, {str(e)}") return [], {'error': f'文件编码错误, 可能是二进制文件'} except Exception as e: # 其他未预期的错误 logger.log('WEBUI', f"读取文件内容出错: {rel_path}, {str(e)}") logger.log('WEBUI', traceback.format_exc()) return [], {'error': f'读取文件出错: {str(e)}'} def search_in_file(self, rel_path: str = '', query: str = '', max_results: int = 100) -> List[Dict[str, Any]]: """在文件中搜索指定内容 在指定文件中搜索字符串,返回匹配的行信息,包括行号、内容和匹配位置。 Args: rel_path: 相对于根目录的文件路径 query: 要搜索的字符串 max_results: 最大结果数 Returns: List[Dict[str, Any]]: 搜索结果列表,每项包含行号、内容和匹配位置 """ if not query: logger.log('WEBUI', "搜索查询为空,返回空结果") return [] try: file_path = self._validate_path(rel_path) if not file_path.exists() or not file_path.is_file(): logger.log('WEBUI', f"搜索的文件不存在或不是常规文件: {rel_path}") return [] results = [] with open(file_path, 'r', encoding='utf-8', errors='replace') as f: for i, line in enumerate(f): if query.lower() in line.lower(): results.append({ 'line_number': i + 1, # 从1开始的行号 'content': line.rstrip('\n'), 'match_position': line.lower().find(query.lower()) }) if len(results) >= max_results: logger.log('WEBUI', f"搜索结果达到上限 {max_results},停止搜索") break logger.log('WEBUI', f"在文件 {rel_path} 中搜索 '{query}',找到 {len(results)} 条匹配") return results except (SecurityError, PathValidationError) as e: logger.log('WEBUI', f"搜索文件安全错误: {str(e)}") return [] except UnicodeDecodeError as e: logger.log('WEBUI', f"搜索文件编码错误: {rel_path}, {str(e)}") return [] except Exception as e: logger.log('WEBUI', f"搜索文件内容出错: {rel_path}, {str(e)}") logger.log('WEBUI', traceback.format_exc()) return [] def save_file_content(self, rel_path: str, content: str) -> bool: """保存文件内容 将内容写入指定文件,如果文件不存在则创建。 Args: rel_path: 相对于根目录的文件路径 content: 要保存的文件内容 Returns: bool: 是否成功保存 """ try: # 验证路径 file_path = self._validate_path(rel_path) # 确保目标是文件而不是目录 if file_path.is_dir(): logger.log('WEBUI', f"保存失败: 目标是目录,不能保存内容: {rel_path}") raise ValueError("目标是目录,不能保存内容") # 确保父目录存在 file_path.parent.mkdir(parents=True, exist_ok=True) # 写入文件内容 with open(file_path, 'w', encoding='utf-8') as f: f.write(content) logger.log('WEBUI', f"文件内容保存成功: {rel_path}, 大小: {len(content)} 字符") return True except (SecurityError, PathValidationError) as e: logger.log('WEBUI', f"保存文件安全错误: {str(e)}") return False except Exception as e: logger.log('WEBUI', f"保存文件异常: {str(e)}") logger.log('WEBUI', traceback.format_exc()) return False # 创建文件服务实例 file_service = FileService() ================================================ FILE: WebUI/services/plugin_service.py ================================================ import asyncio import os import sys from pathlib import Path from typing import List, Dict, Any, Optional from loguru import logger from WebUI.common.bot_bridge import bot_bridge from WebUI.utils.singleton import Singleton # 确保可以导入根目录模块 ROOT_DIR = Path(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) sys.path.insert(0, str(ROOT_DIR)) def get_event_loop(): """ 获取一个可用的事件循环,如果当前循环已关闭则创建新循环 Returns: asyncio.AbstractEventLoop: 事件循环 """ try: loop = asyncio.get_event_loop() if loop.is_closed(): logger.log('WEBUI', f"插件服务: 检测到事件循环已关闭,创建新循环") loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) return loop class PluginService(metaclass=Singleton): """插件服务类,提供插件管理功能""" def __init__(self): """初始化插件服务""" # 配置目录 self.config_dir = ROOT_DIR / 'plugins' def get_all_plugins(self) -> List[Dict[str, Any]]: """获取所有插件列表""" return bot_bridge.get_all_plugins() def get_plugin_details(self, plugin_name: str) -> Optional[Dict[str, Any]]: """获取指定插件的详细信息""" return bot_bridge.get_plugin_details(plugin_name) async def enable_plugin(self, plugin_name: str) -> bool: """启用插件""" return await bot_bridge.enable_plugin(plugin_name) async def disable_plugin(self, plugin_name: str) -> bool: """禁用插件""" return await bot_bridge.disable_plugin(plugin_name) async def reload_plugin(self, plugin_name: str) -> bool: """重新加载插件""" return await bot_bridge.reload_plugin(plugin_name) def run_async(self, coro): """ 安全地执行异步协程 Args: coro: 协程对象 Returns: 协程执行结果 """ loop = get_event_loop() if loop.is_running(): # 如果循环正在运行,使用run_coroutine_threadsafe future = asyncio.run_coroutine_threadsafe(coro, loop) return future.result(timeout=30) # 设置30秒超时 else: # 否则直接运行 return loop.run_until_complete(coro) def save_plugin_config(self, plugin_name: str, config_data: Dict[str, Any]) -> bool: """ 保存插件配置 Args: plugin_name: 插件名称 config_data: 配置数据 Returns: bool: 保存是否成功 """ try: # 实现保存插件配置的逻辑 # 这里是一个占位实现,您需要根据实际情况完善 logger.log('WEBUI', f"保存插件 {plugin_name} 配置") return True except Exception as e: logger.log('WEBUI', f"保存插件配置失败: {str(e)}") return False plugin_service = PluginService() ================================================ FILE: WebUI/services/tool_service.py ================================================ import json import os import pathlib import traceback from typing import Dict, List, Any, Optional, Callable from loguru import logger # 工具注册表 _TOOLS_REGISTRY: Dict[str, Dict[str, Any]] = {} def register_tool(tool_id: str, title: str, description: str, icon: str, handler_func: Callable, params: Optional[List[Dict[str, Any]]] = None) -> bool: """ 注册工具到工具注册表 Args: tool_id: 工具ID,唯一标识 title: 工具标题 description: 工具描述 icon: 工具图标(Font Awesome图标名) handler_func: 处理函数 params: 工具参数定义,默认为None Returns: bool: 注册是否成功 Raises: ValueError: 当处理函数不可调用时 """ if tool_id in _TOOLS_REGISTRY: logger.log('WEBUI', f"工具 {tool_id} 已存在,将被覆盖") # 检查处理函数是否有效 if not callable(handler_func): error_msg = f"工具 {tool_id} 的处理函数必须是可调用的" logger.log('WEBUI', f"注册工具失败: {error_msg}") raise ValueError(error_msg) # 注册工具 _TOOLS_REGISTRY[tool_id] = { 'id': tool_id, 'title': title, 'description': description, 'icon': icon, 'handler': handler_func, 'params': params or [] } logger.log('WEBUI', f"工具 {tool_id} 已注册成功") return True def get_tools_list() -> List[Dict[str, Any]]: """ 获取所有注册的工具列表(不包含处理函数) Returns: List[Dict[str, Any]]: 工具列表,每个工具包含id、title、description、icon和params """ # 确保工具已加载 load_built_in_tools() # 返回工具列表(不包含处理函数) tools = [] for tool_id, tool in _TOOLS_REGISTRY.items(): tools.append({ 'id': tool['id'], 'title': tool['title'], 'description': tool['description'], 'icon': tool['icon'], 'params': tool['params'] }) logger.log('WEBUI', f"获取工具列表,共 {len(tools)} 个工具") return tools def execute_tool(tool_id: str) -> Dict[str, Any]: """ 执行指定ID的工具 Args: tool_id: 工具ID Returns: Dict[str, Any]: 执行结果 Raises: ValueError: 当工具不存在时 """ # 确保工具已加载 load_built_in_tools() # 检查工具是否存在 if tool_id not in _TOOLS_REGISTRY: error_msg = f"工具 {tool_id} 不存在" logger.log('WEBUI', f"执行工具失败: {error_msg}") raise ValueError(error_msg) tool = _TOOLS_REGISTRY[tool_id] handler = tool['handler'] try: logger.log('WEBUI', f"开始执行工具: {tool_id}") # 执行工具处理函数 result = handler() # 默认返回格式 if result is None: result = {'success': True} elif not isinstance(result, dict): result = {'success': True, 'data': result} # 确保包含成功标志 if 'success' not in result: result['success'] = True logger.log('WEBUI', f"工具 {tool_id} 执行成功") return result except Exception as e: error_msg = f"执行工具 {tool_id} 出错: {str(e)}" logger.log('WEBUI', error_msg) logger.log('WEBUI', traceback.format_exc()) return { 'success': False, 'error': str(e), 'stack': traceback.format_exc() } def load_built_in_tools() -> None: """ 加载内置工具 该函数会注册所有系统内置的工具。如需添加新工具,请在此处注册。 """ # 如果已经加载,则跳过 if _TOOLS_REGISTRY: return logger.log('WEBUI', "开始加载内置工具") # 注册内置工具 register_tool( tool_id='reset_account', title='登录新账号', description='删除当前保存的账号文件,以登录新账号', icon='user-plus', handler_func=reset_account_handler ) # 可以在这里注册更多内置工具 logger.log('WEBUI', f"内置工具加载完成,共 {len(_TOOLS_REGISTRY)} 个工具") def reset_account_handler() -> Dict[str, Any]: """ 重置账号文件处理函数 删除现有账号信息,创建一个新的空账号文件,用于重新登录 Returns: Dict[str, Any]: 执行结果 """ try: # 账号文件路径 account_path = pathlib.Path("resource/robot_stat.json") # 检查文件是否存在 if not os.path.exists(account_path): error_msg = '账号文件不存在' logger.log('WEBUI', f"重置账号失败: {error_msg}") return { 'success': False, 'error': error_msg } # 创建新的账号数据 new_data = { "wxid": "", "device_name": "", "device_id": "" } # 写入文件 with open(account_path, 'w', encoding='utf-8') as f: json.dump(new_data, f, ensure_ascii=False, indent=4) logger.log('WEBUI', "账号文件已成功重置") return { 'success': True, 'message': '账号文件已重置' } except Exception as e: error_msg = f"重置账号文件失败: {str(e)}" logger.log('WEBUI', error_msg) logger.log('WEBUI', traceback.format_exc()) return { 'success': False, 'error': error_msg } ================================================ FILE: WebUI/services/websocket_service.py ================================================ import os import threading import time from pathlib import Path from flask_socketio import SocketIO, emit from loguru import logger from WebUI.services.data_service import BOT_LOG_PATH # 创建SocketIO实例 - 但不在这里初始化,而是通过工厂函数传入 socketio = SocketIO() class LogWatcher: """日志监控器,用于实时推送新的日志""" def __init__(self, socketio_instance): """初始化日志监控器 Args: socketio_instance: SocketIO实例,用于推送WebSocket消息 """ self.socketio = socketio_instance self.running = False self.watch_thread = None self.last_position = 0 self.last_emit_time = 0 self.buffer = [] self.throttle_interval = 1.0 # 限制发送频率为1秒 self._init_watcher() def _init_watcher(self): """初始化日志监控器,记录当前日志文件大小作为起始位置""" if isinstance(BOT_LOG_PATH, str): log_path = Path(BOT_LOG_PATH) else: log_path = BOT_LOG_PATH if log_path.exists(): self.last_position = os.path.getsize(log_path) else: self.last_position = 0 logger.warning(f"日志文件不存在: {log_path}") def start(self): """启动日志监控线程""" if self.running: return self.running = True self.watch_thread = threading.Thread(target=self._watch_log_file, daemon=True) self.watch_thread.start() def stop(self): """停止日志监控线程""" self.running = False if self.watch_thread and self.watch_thread.is_alive(): self.watch_thread.join(timeout=1.0) logger.info("WebSocket日志监控服务已关闭") def _should_ignore_log(self, log_line): """判断是否应该忽略某条日志 Args: log_line: 日志行文本 Returns: bool: 如果应该忽略则返回True """ # 忽略WebSocket自身的调试日志,避免循环 if "WebUI.services.websocket_service" in log_line and "已推送" in log_line: return True # 忽略SocketIO的emitting日志 if log_line.startswith("emitting event"): return True # 忽略空日志 if not log_line.strip(): return True return False def _emit_logs(self): """发送缓冲区中的日志""" if not self.buffer: return # 过滤掉应该忽略的日志 filtered_logs = [log for log in self.buffer if not self._should_ignore_log(log)] # 如果过滤后还有日志需要发送 if filtered_logs: self.socketio.emit('new_logs', {'logs': filtered_logs}) # 无论是否发送,都清空缓冲区 self.buffer = [] self.last_emit_time = time.time() def _watch_log_file(self): """监控日志文件变化并推送新日志到WebSocket连接""" if isinstance(BOT_LOG_PATH, str): log_path = Path(BOT_LOG_PATH) else: log_path = BOT_LOG_PATH while self.running: try: if not log_path.exists(): time.sleep(1) continue current_size = os.path.getsize(log_path) # 如果文件大小变小,说明日志被轮转或清空,重置位置 if current_size < self.last_position: self.last_position = 0 # 有新日志 if current_size > self.last_position: with open(log_path, 'r', encoding='utf-8') as f: # 移动到上次读取的位置 f.seek(self.last_position) # 读取新增内容 new_lines = f.readlines() new_logs = [line.strip() for line in new_lines if line.strip()] if new_logs: # 添加到缓冲区 self.buffer.extend(new_logs) # 检查是否应该发送 current_time = time.time() if current_time - self.last_emit_time >= self.throttle_interval: self._emit_logs() # 更新位置指针 self.last_position = current_size # 如果缓冲区有内容且超过了节流时间,发送日志 elif self.buffer and time.time() - self.last_emit_time >= self.throttle_interval: self._emit_logs() except Exception as e: logger.error(f"监控日志文件出错: {str(e)}") # 短暂休眠,避免过高CPU占用 time.sleep(0.5) def get_historical_logs(self, n=100): """获取历史日志 Args: n: 要获取的日志行数,默认100行 Returns: list: 日志行列表 """ try: if isinstance(BOT_LOG_PATH, str): log_path = Path(BOT_LOG_PATH) else: log_path = BOT_LOG_PATH if not log_path.exists(): return [] # 使用tail命令的逻辑,从文件末尾读取n行 with open(log_path, 'r', encoding='utf-8') as f: # 先获取所有行 all_lines = f.readlines() # 获取最后n行 last_n_lines = all_lines[-n:] if len(all_lines) > n else all_lines # 过滤并处理日志行 logs = [line.strip() for line in last_n_lines if line.strip()] filtered_logs = [log for log in logs if not self._should_ignore_log(log)] return filtered_logs except Exception as e: logger.error(f"获取历史日志出错: {str(e)}") return [] # 全局变量,保存LogWatcher实例 log_watcher = None def init_websocket(): """初始化WebSocket服务,启动日志监控器""" global log_watcher # 确保只初始化一次 if log_watcher is None: log_watcher = LogWatcher(socketio) log_watcher.start() # 注册事件处理函数 @socketio.on('request_logs') def handle_request_logs(data): """处理客户端请求日志事件 Args: data: 客户端发送的数据,包含n表示请求的日志行数 """ try: # 获取客户端请求的日志行数,默认100行 n = data.get('n', 100) if isinstance(data, dict) else 100 # 获取历史日志 logs = log_watcher.get_historical_logs(n) # 发送给请求的客户端 emit('new_logs', {'logs': logs}) except Exception as e: logger.error(f"处理日志请求出错: {str(e)}") else: logger.debug("WebSocket日志监控服务已初始化") def shutdown_websocket(): """关闭WebSocket服务,停止日志监控器""" global log_watcher if log_watcher is not None: log_watcher.stop() log_watcher = None logger.info("WebSocket日志监控服务已关闭") ================================================ FILE: WebUI/static/css/components/cards.css ================================================ .status-card { margin-bottom: 20px; } .status-card .card-title { font-size: 2rem; font-weight: bold; } .status-icon { width: 60px; height: 60px; display: flex; align-items: center; justify-content: center; } .control-card .card-body { padding: 1.25rem; } .log-card .card-body { padding: 0; } .tool-card { transition: transform 0.3s; } .tool-card:hover { transform: translateY(-5px); } .status-indicator { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 5px; } .status-online { background-color: #28a745; } .status-offline { background-color: #dc3545; } ================================================ FILE: WebUI/static/css/components/file_browser.css ================================================ .file-browser-container { margin-bottom: 20px; } .file-path-breadcrumb { font-size: 0.9rem; margin-bottom: 15px; border-radius: 4px; position: relative; } .file-path-breadcrumb .breadcrumb-item + .breadcrumb-item::before { content: "/"; float: none; padding-right: 0.5rem; padding-left: 0.5rem; user-select: text; } .full-path-container { position: relative; width: 100%; } .full-path { position: absolute; top: 0; left: 0; width: 100%; opacity: 0; height: 1px; overflow: hidden; white-space: nowrap; user-select: all; } .file-tree { max-height: 500px; overflow-y: auto; padding: 10px; } .file-tree ul { list-style-type: none; padding-left: 20px; } .file-tree li { padding: 2px 0; } .file-tree .folder-node, .file-tree .file-node { cursor: pointer; padding: 3px 5px; border-radius: 3px; } .file-tree .folder-node:hover, .file-tree .file-node:hover { background-color: #f0f0f0; } .file-tree .folder-node.active, .file-tree .file-node.active { background-color: #e9ecef; } .file-tree .folder-icon { color: #ffc107; } .file-tree .file-icon { color: #6c757d; } .file-list { max-height: 500px; overflow-y: auto; } .file-list table { margin-bottom: 0; } .file-list .file-item { cursor: pointer; } .file-list .file-item:hover { background-color: #f8f9fa; } .file-grid { display: flex; flex-wrap: wrap; padding: 10px; gap: 15px; } .file-grid-item { width: 100px; height: 100px; border: 1px solid #eee; border-radius: 5px; display: flex; flex-direction: column; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; } .file-grid-item:hover { background-color: #f8f9fa; border-color: #ddd; } .file-grid-item .file-icon { font-size: 2rem; margin-bottom: 5px; } .file-grid-item .file-grid-name { font-size: 0.8rem; text-align: center; overflow: hidden; text-overflow: ellipsis; width: 90%; white-space: nowrap; } .full-path-display { font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-radius: 4px; user-select: text; padding-left: 8px; padding-right: 8px; } .full-path-display:empty { display: none; } ================================================ FILE: WebUI/static/css/components/file_viewer.css ================================================ /* 文件查看器组件样式 */ /* 容器样式 */ .file-viewer-container { margin-bottom: 30px; } /* 头部样式 */ .file-viewer-header { margin-bottom: 15px; } .file-name { color: #2c3e50; margin: 0; font-weight: 500; } /* 文件信息样式 */ .file-info-item { display: inline-flex; align-items: center; padding: 6px 12px; margin-right: 12px; margin-bottom: 6px; background-color: #f8f9fa; border-radius: 4px; font-size: 0.875rem; } .file-info-item strong { color: #495057; margin-right: 6px; } .file-info-item span { color: #6c757d; } /* 编辑器控件样式 */ .editor-controls { display: flex; align-items: center; gap: 12px; margin-bottom: 0; flex: 1; flex-wrap: nowrap; } .control-item { flex: 0 0 auto; min-width: auto; } .control-item .input-group { border-radius: 4px; overflow: hidden; border: 1px solid #ced4da; } .control-item .input-group-text { min-width: 85px; font-size: 0.85rem; background-color: #e9ecef; border: none; border-right: 1px solid #ced4da; color: #495057; font-weight: 500; } .control-item select, .control-item .input-group-text:last-child { font-size: 0.85rem; padding-right: 1.75rem; border: none; background-color: #f8f9fa; } .control-item .form-check-input { margin: 0; cursor: pointer; } /* 编辑器容器样式 */ .monaco-editor-container { border: 1px solid #dee2e6; border-radius: 4px; overflow: hidden; height: 600px !important; width: 100% !important; } /* 加载状态样式 */ .loading-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px; background-color: #f8f9fa; border-radius: 4px; height: 100%; } .loading-container i { font-size: 2rem; color: #6c757d; margin-bottom: 15px; } .loading-container p { color: #6c757d; margin: 0; } /* 操作按钮样式 */ .editor-action-buttons { display: flex; gap: 8px; margin-left: 15px; flex-shrink: 0; } .editor-action-buttons .btn { display: inline-flex; align-items: center; gap: 6px; padding: 0.375rem 0.75rem; font-size: 0.875rem; } .editor-action-buttons .btn i { font-size: 0.875rem; } /* 编辑器头部样式 */ .card-header.bg-light { background-color: #f8f9fa !important; border-bottom: 1px solid #dee2e6; padding: 1rem; } .card-header .d-flex { flex-wrap: wrap; gap: 1rem; } /* 响应式调整 */ @media (max-width: 992px) { .editor-controls { flex-wrap: wrap; } .control-item { flex: 1 1 auto; } .editor-action-buttons { margin-top: 15px; width: 100%; justify-content: flex-end; margin-left: 0; } } ================================================ FILE: WebUI/static/css/components/loading.css ================================================ .loading-container { padding: 15px; } .spinner-border-lg { width: 3rem; height: 3rem; } .full-page-loader { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(255, 255, 255, 0.8); z-index: 9999; display: flex; justify-content: center; align-items: center; } .loader-content { text-align: center; padding: 20px; background-color: white; border-radius: 8px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .content-loader { padding: 15px; background-color: #fff; border-radius: 5px; } .skeleton-line { background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 4px; margin-bottom: 10px; } @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } ================================================ FILE: WebUI/static/css/components/modals.css ================================================ .modal-content { border: none; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); } .modal-header { border-bottom: 1px solid rgba(0, 0, 0, 0.05); } .modal-footer { border-top: 1px solid rgba(0, 0, 0, 0.05); } .modal-body { padding: 1.5rem; } .modal-loading { text-align: center; padding: 2rem; } .modal-loading .spinner-border { width: 3rem; height: 3rem; } .modal-loading p { margin-top: 1rem; color: #6c757d; } .modal-form .form-group { margin-bottom: 1rem; } .modal-form label { font-weight: 500; } .modal-form .form-text { font-size: 0.875rem; color: #6c757d; } .modal-confirm .modal-body { text-align: center; padding: 2rem; } .modal-confirm .icon-box { width: 80px; height: 80px; margin: 0 auto; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-bottom: 1rem; } .modal-confirm .icon-box i { font-size: 2rem; } .modal-confirm.success .icon-box { background-color: #d4edda; color: #28a745; } .modal-confirm.warning .icon-box { background-color: #fff3cd; color: #ffc107; } .modal-confirm.danger .icon-box { background-color: #f8d7da; color: #dc3545; } ================================================ FILE: WebUI/static/css/custom.css ================================================ /* 文件浏览器调整 */ .file-browser-container { border: 1px solid #dee2e6; border-radius: 0.5rem; background: white; } .file-browser-header { padding: 1rem; border-bottom: 1px solid #dee2e6; } /* 优化文件树结构 */ .file-tree { max-height: 70vh; overflow-y: auto; padding: 0.5rem; } .file-tree .folder-node, .file-tree .file-node { padding: 0.25rem 0.5rem; border-radius: 4px; transition: all 0.2s; } .file-tree ul { padding-left: 1rem; } /* 优化文件列表 */ .file-list { max-height: 70vh; border-radius: 0.5rem; } .file-list table { margin-bottom: 0; } .file-item:hover { background-color: #f8f9fa; } /* 调整网格视图 */ .file-grid { gap: 0.75rem; padding: 0.75rem; } .file-grid-item { width: 120px; height: 120px; padding: 0.75rem; } ================================================ FILE: WebUI/static/css/pages/about.css ================================================ /* 关于页面样式 */ .about-container { padding: 20px; } .about-header { margin-bottom: 30px; text-align: center; } .about-header h1 { font-size: 2.5rem; color: #333; margin-bottom: 15px; } .about-section { margin-bottom: 40px; padding: 20px; background: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .about-section h2 { color: #2c3e50; margin-bottom: 20px; padding-bottom: 10px; border-bottom: 2px solid #eee; } .about-section p { color: #666; line-height: 1.6; margin-bottom: 15px; } .version-info { background: #f8f9fa; padding: 15px; border-radius: 6px; margin-bottom: 20px; } .version-info .label { font-weight: bold; color: #495057; margin-right: 10px; } .version-info .value { color: #6c757d; } .system-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; margin-top: 20px; } .system-info-item { background: #f8f9fa; padding: 15px; border-radius: 6px; } .system-info-item .label { font-weight: bold; color: #495057; margin-bottom: 5px; } .system-info-item .value { color: #6c757d; word-break: break-all; } /* 响应式调整 */ @media (max-width: 768px) { .about-container { padding: 15px !important; } .about-header h1 { font-size: 2rem; } .about-section { padding: 15px; } .system-info { grid-template-columns: 1fr; } .lead { font-size: 1.1rem; } } ================================================ FILE: WebUI/static/css/pages/auth.css ================================================ /* 登录页面专用样式 */ .login-container { max-width: 400px; margin: 0 auto; padding: 40px 0; display: flex; flex-direction: column; justify-content: center; min-height: 100vh; } .login-header { text-align: center; margin-bottom: 30px; } .login-header h1 { font-size: 2.2rem; font-weight: 600; color: #333; } .login-card { border-radius: 10px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12); border: none; } .login-card .card-title { color: #444; font-weight: 500; } .login-footer { margin-top: 2rem; text-align: center; } /* 输入框样式优化 */ .login-card .form-control { padding: 0.75rem 1rem; border-radius: 5px; } .login-card .btn-primary { padding: 0.75rem 1.5rem; font-weight: 500; border-radius: 5px; } /* 移除通知容器位置调整,使用style.css中的统一定义 */ ================================================ FILE: WebUI/static/css/pages/config.css ================================================ /* 配置页面样式 */ .card { margin-top: 15px; margin-bottom: 15px; } .config-section { margin-bottom: 20px; border: 1px solid #ddd; border-radius: 5px; overflow: hidden; } .config-header { background-color: #f8f9fa; padding: 10px 15px; border-bottom: 1px solid #ddd; cursor: pointer; display: flex; justify-content: space-between; align-items: center; } .config-header h5 { margin: 0; } .config-body { padding: 15px; } .config-field { margin-bottom: 15px; } .config-field label { font-weight: 500; } .config-field .form-text { font-size: 0.85rem; } .save-btn-fixed { position: fixed; bottom: 20px; right: 20px; z-index: 1000; } .array-item { margin-bottom: 10px; display: flex; align-items: center; } .array-item .form-control { flex: 1; margin-right: 10px; } .array-controls { margin-top: 10px; } .object-property { margin-bottom: 15px; border: 1px solid #eee; border-radius: 5px; padding: 10px; } /* 配置表单验证样式 */ .was-validated .form-control:invalid { border-color: #dc3545; padding-right: calc(1.5em + 0.75rem); background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right calc(0.375em + 0.1875rem) center; background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } .was-validated .form-control:valid { border-color: #28a745; padding-right: calc(1.5em + 0.75rem); background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right calc(0.375em + 0.1875rem) center; background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } /* 配置折叠图标动画 */ .config-header i { transition: transform 0.2s ease-in-out; } .config-header[aria-expanded="true"] i { transform: rotate(180deg); } /* 配置描述文本样式 */ .config-description { color: #6c757d; font-size: 0.9rem; line-height: 1.5; } /* 配置字段组样式 */ .config-group { border: 1px solid #e9ecef; border-radius: 4px; padding: 15px; margin-bottom: 20px; } .config-group-title { font-size: 1rem; font-weight: 500; margin-bottom: 15px; color: #495057; } /* 响应式调整 */ @media (max-width: 768px) { .save-btn-fixed { width: calc(100% - 30px); right: 15px; bottom: 15px; } .config-header { padding: 8px 12px; } .config-body { padding: 12px; } .array-item { flex-direction: column; } .array-item .form-control { margin-right: 0; margin-bottom: 10px; } .array-item .btn { width: 100%; } } ================================================ FILE: WebUI/static/css/pages/explorer.css ================================================ /* 文件浏览器页面样式 */ .explorer-container { min-height: calc(100vh - 120px); } /* 文件浏览器头部 */ .explorer-header { background-color: #f8f9fa; border-bottom: 1px solid #dee2e6; padding: 1rem; margin-bottom: 1rem; } .explorer-title { font-size: 1.25rem; font-weight: 500; margin: 0; } /* 文件浏览器工具栏 */ .explorer-toolbar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; } .explorer-toolbar .btn-group { margin-left: auto; } /* 文件浏览器内容区 */ .explorer-content { background-color: #fff; border: 1px solid #dee2e6; border-radius: 0.25rem; padding: 1rem; } /* 文件浏览器面包屑导航 */ .explorer-breadcrumb { background-color: transparent; padding: 0; margin-bottom: 1rem; } .explorer-breadcrumb .breadcrumb-item + .breadcrumb-item::before { content: "/"; } /* 文件浏览器列表视图 */ .explorer-list { width: 100%; } .explorer-list th { font-weight: 500; border-top: none; } .explorer-list td { vertical-align: middle; } /* 文件浏览器网格视图 */ .explorer-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 1rem; padding: 1rem; } .explorer-grid-item { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 1rem; border: 1px solid #dee2e6; border-radius: 0.25rem; cursor: pointer; transition: all 0.2s ease-in-out; } .explorer-grid-item:hover { background-color: #f8f9fa; border-color: #adb5bd; } .explorer-grid-item i { font-size: 2rem; margin-bottom: 0.5rem; } .explorer-grid-item span { font-size: 0.875rem; word-break: break-word; } /* 文件图标颜色 */ .file-icon.folder { color: #ffd700; } .file-icon.image { color: #28a745; } .file-icon.text { color: #17a2b8; } .file-icon.code { color: #007bff; } .file-icon.archive { color: #6c757d; } /* 响应式调整 */ @media (max-width: 768px) { .explorer-grid { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); } .explorer-toolbar { flex-wrap: wrap; } .explorer-toolbar .btn-group { margin-left: 0; margin-top: 0.5rem; width: 100%; } .explorer-toolbar .btn-group .btn { flex: 1; } } ================================================ FILE: WebUI/static/css/pages/logs.css ================================================ /* 日志页面特定样式 */ .logs-container { padding-top: 1.5rem; padding-bottom: 2rem; min-height: calc(100vh - 60px); } /* 日志容器样式 */ .log-container { height: 400px; margin-bottom: 15px; } /* 控制按钮样式 */ .log-controls { margin-bottom: 10px; } .log-controls button { margin-right: 5px; } /* 控制面板样式 */ .log-panel { margin-bottom: 20px; } .log-panel .card-header { display: flex; justify-content: space-between; align-items: center; } /* 暗色主题样式调整 */ [data-bs-theme="dark"] .log-container, [data-bs-theme="dark"] .log-viewer { background-color: #121212; border: 1px solid #333; } /* 页脚间距 */ .page-footer-space { height: 30px; } ================================================ FILE: WebUI/static/css/pages/overview.css ================================================ /* 总览页面样式 */ /* 页面布局 */ .main-content { padding-top: 1.5rem; padding-bottom: 2rem; min-height: calc(100vh - 60px); /* 防止页面过短导致滚动问题 */ } /* 状态图标 */ .status-icon { transition: all 0.2s ease-in-out; } .status-icon:hover { transform: scale(1.05); } /* 日志查看器 */ .log-viewer { background-color: #1e1e1e; color: #f0f0f0; font-family: monospace; font-size: 14px; overflow-y: auto; border-radius: 5px; padding: 0; white-space: pre; overscroll-behavior: contain; height: 450px; } .log-line { padding: 0 10px; display: block; letter-spacing: 0; line-height: 1; } .log-line:hover { background-color: rgba(255, 255, 255, 0.1); } /* 日志级别样式 */ .log-debug { color: rgba(14, 102, 234, 0.8); } .log-info { color: #ffffff; } .log-warning { color: #ffea00; } .log-error { color: #f14c4c; } .log-success { color: #01C801FF } .log-critical { color: #ff0000; } .log-webui { color: #2493cb; } /* 页面标题 */ .xybot-title { font-size: 1.5rem; font-weight: bold; color: #333; } /* 页面底部空间 */ .page-footer-space { height: 30px; } /* 卡片样式 */ .card { border: none; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); transition: all 0.3s ease; } .card:hover { box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .card-header { background-color: transparent; border-bottom: 1px solid rgba(0, 0, 0, 0.1); } .card-title { color: #2c3e50; margin-bottom: 0; } /* 表格样式 */ .table-sm th { font-weight: 500; color: #6c757d; } .table-sm td { color: #2c3e50; text-align: right; } /* 确保状态徽章对齐一致 */ .table-sm td .badge { display: inline-block; min-width: 60px; } /* 按钮样式 */ .btn { transition: all 0.2s ease; } .btn:hover { transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .btn:active { transform: translateY(0); box-shadow: none; } /* 账号信息卡片样式 */ .account-info-card { height: 100%; } .account-info-card .card-body { padding: 2rem 1.5rem; } /* 头像样式 */ .profile-picture-container { position: relative; width: 150px; height: 150px; min-width: 150px; /* 防止容器收缩 */ background-color: #f0f0f0; margin: 0 auto; } .profile-picture { width: 100%; height: 100%; border-radius: 0; background-color: #f5f5f5; display: flex; align-items: center; justify-content: center; overflow: hidden; border: none; box-shadow: none; } .profile-picture img { width: 100%; height: 100%; object-fit: cover; } .profile-picture i { font-size: 5rem; color: #aaa; } /* 响应式调整 */ @media (max-width: 768px) { .main-content { padding-top: 1rem; padding-bottom: 1rem; } .log-viewer { height: 300px; } .card { margin-bottom: 1rem; } .btn { padding: 0.375rem 0.75rem; font-size: 0.875rem; } .profile-picture-container { width: 100px; height: 100px; } .profile-picture i { font-size: 3.5rem; } } ================================================ FILE: WebUI/static/css/pages/plugin.css ================================================ /* 插件管理页面样式 */ /* 容器样式 */ .plugin-container { padding: 1.5rem 1rem; } /* 头部样式 */ .plugin-header { margin-bottom: 1.5rem; } /* 搜索框样式 */ .plugin-search { width: 200px; margin-left: 8px; } .plugin-search .form-control { border-radius: 4px 0 0 4px; padding: 0.25rem 1rem; transition: all 0.2s ease; } .plugin-search .input-group-text { border-radius: 0 4px 4px 0; background-color: #f8f9fa; border-left: none; } .plugin-search .form-control:focus { box-shadow: 0 0 0 0.2rem rgba(58, 134, 255, 0.25); border-color: #3a86ff; z-index: 3; } /* 插件卡片样式 */ .plugin-card { margin-bottom: 1rem; transition: all 0.3s ease; } .plugin-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } .plugin-info { padding: 1rem; } .plugin-title { font-size: 1.25rem; font-weight: 600; color: #2c3e50; margin-bottom: 0.5rem; } .plugin-meta { font-size: 0.875rem; color: #6c757d; margin-bottom: 0.5rem; } .plugin-description { color: #495057; font-size: 0.9rem; line-height: 1.5; } /* 按钮样式 */ .btn-soft-primary { color: #3a86ff; background-color: rgba(58, 134, 255, 0.1); border: 1px solid rgba(58, 134, 255, 0.1); } .btn-soft-primary:hover { color: white; background-color: #3a86ff; } .btn-soft-success { color: #38b000; background-color: rgba(56, 176, 0, 0.1); border: 1px solid rgba(56, 176, 0, 0.1); } .btn-soft-success:hover { color: white; background-color: #38b000; } .btn-soft-danger { color: #ff006e; background-color: rgba(255, 0, 110, 0.1); border: 1px solid rgba(255, 0, 110, 0.1); } .btn-soft-danger:hover { color: white; background-color: #ff006e; } .btn-soft-secondary { color: #6c757d; background-color: rgba(108, 117, 125, 0.1); border: 1px solid rgba(108, 117, 125, 0.1); } .btn-soft-secondary:hover { color: white; background-color: #6c757d; } /* 按钮动效 */ .plugin-action-btn, .reload-btn, .config-btn { transition: all 0.2s ease; min-width: 38px; } .plugin-action-btn { min-width: 100px; } .plugin-action-btn:not(:disabled):hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .reload-btn:not(:disabled):hover { transform: rotate(360deg); } .config-btn:not(:disabled):hover { transform: scale(1.1); } .btn:disabled { opacity: 0.5; cursor: not-allowed; } /* 按钮间距 */ .gap-3 > * { margin: 0 4px; } .gap-4 { gap: 1.5rem !important; } /* 插件详情模态框样式 */ .plugin-detail-modal .modal-body { padding: 1.5rem; } .plugin-detail-tabs { margin-bottom: 1.5rem; } .plugin-info-section { margin-bottom: 2rem; } .plugin-info-section dt { color: #6c757d; font-weight: 500; } .plugin-info-section dd { color: #2c3e50; } /* Markdown内容样式 */ .markdown-content { padding: 1rem; background-color: #f8f9fa; border-radius: 4px; } /* 配置编辑器样式 */ #plugin-config-editor { height: 400px; margin-bottom: 1rem; } /* 文件浏览器模态框样式 */ #fileBrowserModal .modal-dialog { max-width: 90%; height: 90vh; } #fileBrowserModal .modal-body { height: calc(90vh - 120px); overflow: hidden; } #plugin-file-browser .file-list { max-height: none; height: 100%; } /* 响应式调整 */ @media (max-width: 768px) { .plugin-search { width: 150px; } .plugin-action-btn { min-width: 80px; } .plugin-card .card-body { padding: 1rem; } .plugin-meta { flex-direction: column; } .plugin-meta > * { margin-bottom: 0.5rem; } } ================================================ FILE: WebUI/static/css/pages/tools.css ================================================ /* 工具箱页面样式 */ /* 工具卡片样式 */ .tool-card { transition: all 0.3s ease; border: 1px solid #dee2e6; border-radius: 0.25rem; overflow: hidden; height: 100%; display: flex; flex-direction: column; } /* 工具卡片头部 */ .tool-header { background-color: #f8f9fa; padding: 15px; display: flex; align-items: center; border-bottom: 1px solid #dee2e6; } .tool-icon { font-size: 2rem; margin-right: 15px; color: #007bff; } .tool-title { margin: 0; font-size: 1.2rem; font-weight: 500; } /* 工具卡片内容 */ .tool-body { padding: 15px; flex-grow: 1; } .tool-description { color: #6c757d; margin-bottom: 15px; } /* 工具卡片底部 */ .tool-footer { padding: 15px; border-top: 1px solid #dee2e6; display: flex; justify-content: flex-end; } /* 执行状态样式 */ .execution-status { margin-top: 10px; padding: 10px; border-radius: 5px; display: none; } .execution-status.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .execution-status.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } .execution-status.loading { background-color: #e2e3e5; color: #383d41; border: 1px solid #d6d8db; } /* 执行日志样式 */ .execution-log { max-height: 300px; overflow-y: auto; font-family: monospace; background-color: #f8f9fa; padding: 10px; border-radius: 5px; font-size: 0.9rem; margin-top: 10px; white-space: pre-wrap; word-break: break-all; } /* 加载状态样式 */ .loading-container { text-align: center; padding: 3rem 0; } .loading-container .spinner-border { width: 3rem; height: 3rem; } .loading-container p { margin-top: 1rem; color: #6c757d; } /* 响应式调整 */ @media (max-width: 768px) { .tool-header { padding: 12px; } .tool-icon { font-size: 1.5rem; margin-right: 12px; } .tool-title { font-size: 1.1rem; } .tool-body, .tool-footer { padding: 12px; } .execution-log { max-height: 200px; font-size: 0.8rem; } } ================================================ FILE: WebUI/static/css/style.css ================================================ /* ========== 全局基础样式 ========== */ body { padding-top: 0; min-height: 100vh; display: flex; flex-direction: column; overflow-x: hidden; } .content { flex: 1; overflow-y: auto; height: 100vh; margin-left: 16.666667%; width: 83.333333%; padding: 15px; } /* ========== 侧边栏样式 ========== */ .sidebar { position: fixed; top: 0; left: 0; width: 16.666667%; height: 100vh; background-color: #343a40; overflow-y: hidden; z-index: 100; display: flex; flex-direction: column; } .sidebar-header { padding: 15px; background-color: #2c3136; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .sidebar-header .navbar-brand { color: #fff; font-size: 1.25rem; margin-right: 0; font-weight: bold; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sidebar-actions { display: flex; align-items: center; padding: 10px 15px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .sidebar-actions .nav-link { color: rgba(255, 255, 255, 0.75); padding: 5px 10px; font-size: 0.9rem; } .sidebar-actions .nav-link:hover { color: #fff; } .sidebar-content { padding-top: 15px; overflow-y: hidden; flex: 1; display: flex; flex-direction: column; justify-content: space-between; } .sidebar-menu { flex: 1; } .sidebar-footer { padding: 10px 15px; border-top: 1px solid rgba(255, 255, 255, 0.1); } .sidebar .nav-link { color: rgba(255, 255, 255, 0.75); padding: 8px 15px; } .sidebar .nav-link:hover { color: rgba(255, 255, 255, 1); } .sidebar .nav-link.active { color: #fff; background-color: rgba(255, 255, 255, 0.1); } .sidebar .nav-link i { margin-right: 10px; width: 20px; text-align: center; } /* ========== 页脚样式 ========== */ .footer { background-color: #f5f5f5; padding: 1rem 0; margin-top: auto; width: 83.333333%; margin-left: 16.666667%; } /* ========== 通用卡片样式 ========== */ .card { margin-bottom: 20px; border: none; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); transition: all 0.3s ease; } .card:hover { box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .card-header { background-color: transparent; border-bottom: 1px solid rgba(0, 0, 0, 0.1); } .card-title { color: #2c3e50; margin-bottom: 0; } /* ========== 通用按钮样式 ========== */ .btn { transition: all 0.2s ease; } .btn:hover { transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .btn:active { transform: translateY(0); box-shadow: none; } .btn-primary { background-color: #3a86ff; border-color: #3a86ff; padding: 0.5rem 1.5rem; } .btn-primary:hover { background-color: #2c3e50; border-color: #2c3e50; transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .btn-primary:active { transform: translateY(0); } .btn-icon { padding: 0.25rem 0.5rem; } /* ========== 通用表格样式 ========== */ .table-sm th { font-weight: 500; color: #6c757d; } .table-sm td { color: #2c3e50; } .table-hover tbody tr:hover { background-color: rgba(0, 0, 0, 0.03); } /* ========== 纯净通知系统 ========== */ #notificationContainer { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 9999; pointer-events: none; display: flex; flex-direction: column; align-items: center; } .pure-notification { background: #fff; padding: 0.75rem 1.25rem; border-radius: 4px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); margin-bottom: 10px; opacity: 0; transform: translateY(-20px); transition: all 0.3s ease-in-out; pointer-events: auto; text-align: center; white-space: nowrap; display: inline-block; min-width: min-content; } /* 添加.show类以确保通知显示动画正常工作 */ .pure-notification.show { opacity: 1; transform: translateY(0); } /* 删除所有旧的notification-toast相关样式 */ .notification-toast { display: none !important; } /* 确保登录页面也能正确显示通知 */ body.login-page #notificationContainer { position: fixed !important; top: 20px !important; left: 50% !important; transform: translateX(-50%) !important; z-index: 9999 !important; align-items: center !important; } /* 添加通知类型样式 */ .pure-notification.info-notification { background: #e3f2fd !important; border-color: #90caf9 !important; } .pure-notification.success-notification { background: #e8f5e9 !important; border-color: #a5d6a7 !important; } .pure-notification.warning-notification { background: #fff3e0 !important; border-color: #ffcc80 !important; } .pure-notification.error-notification { background: #ffebee !important; border-color: #ef9a9a !important; } /* ========== 自定义滚动条 ========== */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { background: #f1f1f1; } ::-webkit-scrollbar-thumb { background: #888; border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: #555; } /* ========== 日志查看器通用样式 ========== */ .log-viewer { background-color: #1e1e1e; color: #f0f0f0; font-family: 'Consolas', 'Courier New', monospace; font-size: 14px; max-height: 400px; overflow-y: auto; border-radius: 5px; border: 1px solid #444; padding: 0; margin-bottom: 15px; white-space: pre-wrap; word-wrap: break-word; overscroll-behavior: contain; position: relative; } .log-line { padding: 2px 10px; display: block; border-bottom: 1px solid rgba(255, 255, 255, 0.05); white-space: pre-wrap; word-wrap: break-word; } .log-line:hover { background-color: rgba(255, 255, 255, 0.1); } /* 日志级别样式 */ .log-debug { color: #42a7ff; } .log-info { color: #ffffff; } .log-warning { color: #ffc107; } .log-error { color: #f14c4c; } .log-critical { color: #ff0000; font-weight: bold; background-color: rgba(255, 0, 0, 0.2); } /* ========== 加载提示样式 ========== */ .loading-container { text-align: center; padding: 3rem 0; } .loading-container .spinner-border { width: 3rem; height: 3rem; } .loading-container p { margin-top: 1rem; color: #6c757d; } .full-page-loader { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(255, 255, 255, 0.8); z-index: 9999; display: flex; justify-content: center; align-items: center; } .loader-content { text-align: center; padding: 20px; background-color: white; border-radius: 8px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } /* ========== 响应式调整 ========== */ @media (max-width: 768px) { .content { margin-left: 0; width: 100%; } .footer { width: 100%; margin-left: 0; } .sidebar { transform: translateX(-100%); transition: transform 0.3s ease; } .sidebar.show { transform: translateX(0); } .btn { padding: 0.375rem 0.75rem; font-size: 0.875rem; } .loading-container .spinner-border { width: 2rem; height: 2rem; } } /* ========== 登录页面通用样式 ========== */ body.login-page { background-color: #f8f9fa; padding-top: 0 !important; } body.login-page .navbar, body.login-page .sidebar { display: none !important; } body.login-page .content, body.login-page main { margin-left: 0 !important; padding-left: 0 !important; width: 100% !important; } /* ========== 模态框样式 ========== */ .modal-content { border: none; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); } .modal-header { border-bottom: 1px solid rgba(0, 0, 0, 0.05); } .modal-footer { border-top: 1px solid rgba(0, 0, 0, 0.05); } .modal-body { padding: 1.5rem; } /* ========== 内容骨架屏 ========== */ .content-loader { padding: 15px; background-color: #fff; border-radius: 5px; } .skeleton-line { background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 4px; margin-bottom: 10px; } @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } /* 状态指示器 */ .status-indicator { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 5px; } .status-online { background-color: #28a745; } .status-offline { background-color: #dc3545; } /* 状态卡片 */ .status-card .card-title { font-size: 2rem; font-weight: bold; } .status-icon { width: 60px; height: 60px; display: flex; align-items: center; justify-content: center; } /* 控制卡片 */ .control-card .card-body { padding: 1.25rem; } /* 日志卡片 */ .log-card .card-body { padding: 0; } /* 工具卡片 */ .tool-card { transition: transform 0.3s; } .tool-card:hover { transform: translateY(-5px); } /* 登录页面特殊样式 */ .login-page { background-color: #f8f9fa; } /* 表单样式 */ .form-control:focus { box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); border-color: #86b7fe; } /* 补充缺失的版本信息样式 */ .version-info { background: #f8f9fa; padding: 15px; border-radius: 6px; margin-bottom: 20px; } /* 添加全局按钮重置 */ button.btn-close { display: none !important; visibility: hidden !important; opacity: 0 !important; width: 0 !important; height: 0 !important; padding: 0 !important; margin: 0 !important; } ================================================ FILE: WebUI/static/js/components/cards.js ================================================ class StatusCard { constructor(element) { this.element = element; this.title = element.querySelector('.card-title'); this.icon = element.querySelector('.status-icon i'); this.value = element.querySelector('.card-value'); } update(value) { if (this.value) { this.value.textContent = value; } } setIcon(iconClass) { if (this.icon) { this.icon.className = iconClass; } } } class ControlCard { constructor(element, options = {}) { this.element = element; this.startBtn = element.querySelector('[data-action="start"]'); this.stopBtn = element.querySelector('[data-action="stop"]'); this.statusIndicator = element.querySelector('.status-indicator'); this.options = options; this.init(); } init() { if (this.startBtn) { this.startBtn.addEventListener('click', () => this.start()); } if (this.stopBtn) { this.stopBtn.addEventListener('click', () => this.stop()); } } start() { if (this.options.onStart) { this.options.onStart(); } } stop() { if (this.options.onStop) { this.options.onStop(); } } setStatus(isRunning) { if (this.startBtn) { this.startBtn.style.display = isRunning ? 'none' : 'inline-block'; } if (this.stopBtn) { this.stopBtn.style.display = isRunning ? 'inline-block' : 'none'; } if (this.statusIndicator) { this.statusIndicator.className = `status-indicator ${isRunning ? 'status-online' : 'status-offline'}`; } } } class LogCard { constructor(element, options = {}) { this.element = element; this.logViewer = element.querySelector('.log-viewer'); this.refreshBtn = element.querySelector('[data-action="refresh"]'); this.clearBtn = element.querySelector('[data-action="clear"]'); this.options = options; this.init(); } init() { if (this.refreshBtn) { this.refreshBtn.addEventListener('click', () => this.refresh()); } if (this.clearBtn) { this.clearBtn.addEventListener('click', () => this.clear()); } } refresh() { if (this.options.onRefresh) { this.options.onRefresh(); } } clear() { if (this.logViewer) { this.logViewer.innerHTML = ''; } if (this.options.onClear) { this.options.onClear(); } } appendLog(logText, level = 'info') { if (!this.logViewer) return; const logLine = document.createElement('div'); logLine.className = `log-line log-${level}`; logLine.textContent = logText; this.logViewer.appendChild(logLine); this.scrollToBottom(); } scrollToBottom() { if (this.logViewer) { this.logViewer.scrollTop = this.logViewer.scrollHeight; } } } // 导出组件类 window.StatusCard = StatusCard; window.ControlCard = ControlCard; window.LogCard = LogCard; ================================================ FILE: WebUI/static/js/components/file_browser.js ================================================ (function () { // 检查全局工具函数是否已存在,不存在则初始化 if (!window.FileBrowserUtils) { window.FileBrowserUtils = { getLoadingTemplate: () => `
加载中...
`, handleResponse: async (response, successHandler) => { const data = await response.json(); if (!response.ok) { throw new Error(data.error || `HTTP错误: ${response.status}`); } return successHandler(data); }, handleError: (context, error) => { console.error(`${context}:`, error); alert(`${context}: ${error.message}`); }, formatFileSize: (bytes) => { if (bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + units[i]; }, formatDateTime: (timestamp) => { const date = new Date(timestamp * 1000); return date.toLocaleString(); }, getFileIcon: (filename) => { const extension = filename.split('.').pop().toLowerCase(); const iconMap = { 'txt': 'far fa-file-alt text-secondary', 'log': 'fas fa-file-alt text-info', 'py': 'fab fa-python text-primary', 'js': 'fab fa-js-square text-warning', 'html': 'fab fa-html5 text-danger', 'css': 'fab fa-css3-alt text-primary', 'json': 'fas fa-file-code text-secondary', 'md': 'fas fa-file-alt text-primary', 'jpg': 'far fa-file-image text-success', 'jpeg': 'far fa-file-image text-success', 'png': 'far fa-file-image text-success' }; return iconMap[extension] || 'far fa-file text-secondary'; }, getParentPath: (path) => { if (!path) return ''; const lastSlashIndex = path.lastIndexOf('/'); return lastSlashIndex === -1 ? '' : path.substring(0, lastSlashIndex); } }; } // 全局暴露文件浏览器初始化函数 window.initFileBrowser = function (containerId, initialPath = '') { const container = document.getElementById(containerId); if (!container) return; // 状态管理对象 const state = { currentPath: initialPath, viewMode: 'list' }; // DOM元素引用 const dom = { fileList: document.getElementById(`${containerId}-list`), breadcrumb: container.querySelector('.file-path-breadcrumb'), fullPath: document.getElementById(`${containerId}-full-path`), refreshBtn: document.getElementById(`${containerId}-refresh`), viewToggleBtn: document.getElementById(`${containerId}-view-toggle`) }; // 初始化事件监听 function initEventListeners() { // 面包屑导航 if (dom.breadcrumb) { dom.breadcrumb.addEventListener('click', handleBreadcrumbClick); } // 刷新按钮 if (dom.refreshBtn) { dom.refreshBtn.addEventListener('click', () => { core.loadFileList(state.currentPath); }); } // 视图切换 if (dom.viewToggleBtn) { dom.viewToggleBtn.addEventListener('click', toggleViewMode); } } // 核心功能方法 const core = { // 加载文件列表 async loadFileList(path) { try { const response = await fetch(`/file/api/list?path=${encodeURIComponent(path)}`); await FileBrowserUtils.handleResponse(response, (data) => { if (data.files && Array.isArray(data.files)) { state.currentPath = data.path || path; render.fileList(data.files); updateBreadcrumb(); } else if (Array.isArray(data)) { state.currentPath = path; render.fileList(data); updateBreadcrumb(); } else { console.error('无效的API响应格式', data); dom.fileList.innerHTML = `
无法加载文件列表:无效的数据格式
`; } }); } catch (error) { FileBrowserUtils.handleError('加载文件列表失败', error); dom.fileList.innerHTML = `
加载失败: ${error.message}
`; } } }; // 内部辅助函数 function getFileListTemplate(files, isRoot) { return ` ${!isRoot ? ` ` : ''} ${files.map(file => ` `).join('')}
名称 大小 修改时间
上级目录
${file.name} ${file.is_dir ? '-' : FileBrowserUtils.formatFileSize(file.size)} ${FileBrowserUtils.formatDateTime(file.modified)}
`; } function getFileGridTemplate(files, isRoot) { return `
${!isRoot ? `
上级目录
` : ''} ${files.map(file => `
${file.name}
`).join('')}
`; } // 视图渲染方法 const render = { fileList(files) { files = files.filter(file => { return !file.permissions || (file.permissions & 0o400) !== 0; }); const isRoot = !state.currentPath; const template = state.viewMode === 'list' ? getFileListTemplate(files, isRoot) : getFileGridTemplate(files, isRoot); dom.fileList.innerHTML = template; bindFileItemEvents(); } }; // 事件处理 function handleBreadcrumbClick(e) { if (e.target.tagName === 'A') { e.preventDefault(); const path = e.target.dataset.path || e.target.getAttribute('data-path'); core.loadFileList(path); } } function toggleViewMode() { state.viewMode = state.viewMode === 'list' ? 'grid' : 'list'; core.loadFileList(state.currentPath); updateViewToggleIcon(); } function updateViewToggleIcon() { if (!dom.viewToggleBtn) return; const icon = dom.viewToggleBtn.querySelector('i'); if (icon) { icon.className = state.viewMode === 'list' ? 'fas fa-th' : 'fas fa-th-list'; } } function updateBreadcrumb() { if (dom.fullPath) { let displayPath = state.currentPath; if (!displayPath) { displayPath = '/'; } else if (!displayPath.startsWith('/')) { displayPath = '/' + displayPath; } dom.fullPath.textContent = displayPath; } if (dom.breadcrumb) { dom.breadcrumb.innerHTML = ''; if (!state.currentPath) { dom.breadcrumb.innerHTML = ''; return; } const parts = state.currentPath.split('/'); let currentPath = ''; const rootLi = document.createElement('li'); rootLi.className = 'breadcrumb-item'; const rootLink = document.createElement('a'); rootLink.href = '#'; rootLink.dataset.path = ''; rootLink.textContent = '根目录'; rootLi.appendChild(rootLink); dom.breadcrumb.appendChild(rootLi); parts.forEach((part, index) => { if (!part) return; currentPath += (currentPath ? '/' : '') + part; const li = document.createElement('li'); li.className = 'breadcrumb-item'; if (index === parts.length - 1) { li.classList.add('active'); li.textContent = part; } else { const link = document.createElement('a'); link.href = '#'; link.dataset.path = currentPath; link.textContent = part; li.appendChild(link); } dom.breadcrumb.appendChild(li); }); } } function bindFileItemEvents() { dom.fileList.querySelectorAll('.file-item, .file-grid-item').forEach(item => { item.addEventListener('click', () => { const path = item.dataset.path; const type = item.dataset.type; if (type === 'dir') { core.loadFileList(path); } else { openFileViewer(path); } }); }); } function openFileViewer(path) { window.location.href = `/explorer/view/${encodeURIComponent(path)}`; } // 初始化执行 function init() { initEventListeners(); core.loadFileList(state.currentPath); } init(); } })(); ================================================ FILE: WebUI/static/js/components/file_viewer.js ================================================ (function () { // 检查全局工具函数是否已存在,不存在则初始化 if (!window.FileViewerUtils) { window.FileViewerUtils = { handleResponse: async (response, successHandler) => { if (!response.ok) { const text = await response.text(); try { const data = JSON.parse(text); throw new Error(data.error || `HTTP错误: ${response.status}`); } catch (e) { throw new Error(`服务器错误(${response.status}): ${text.substring(0, 100)}`); } } return successHandler(await response.json()); }, handleError: (context, error) => { console.error(`${context}:`, error); alert(`${context}: ${error.message}`); }, formatFileSize: (bytes) => { if (bytes === 0) return '0 B'; if (bytes === undefined || bytes === null || isNaN(bytes)) return '未知'; try { const units = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + units[i]; } catch (e) { console.error('格式化文件大小出错:', e); return '未知'; } }, formatDateTime: (timestamp) => { if (timestamp === undefined || timestamp === null || isNaN(timestamp)) return '未知'; try { const date = new Date(timestamp * 1000); if (isNaN(date.getTime())) return '未知'; return date.toLocaleString(); } catch (e) { console.error('格式化时间出错:', e); return '未知'; } }, getFileExtension: (filename) => { return filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase(); }, guessLanguage: (filename) => { const ext = window.FileViewerUtils.getFileExtension(filename); const extensionMap = { 'js': 'javascript', 'ts': 'typescript', 'py': 'python', 'html': 'html', 'css': 'css', 'json': 'json', 'md': 'markdown', 'txt': 'plaintext', 'xml': 'xml', 'sh': 'shell', 'bash': 'shell', 'yml': 'yaml', 'yaml': 'yaml', 'toml': 'toml', 'java': 'java', 'c': 'c', 'cpp': 'cpp', 'cs': 'csharp', 'go': 'go', 'php': 'php', 'rb': 'ruby', 'rs': 'rust', 'sql': 'sql' }; return extensionMap[ext] || 'plaintext'; } }; } // 全局暴露文件查看器初始化函数 window.initFileViewer = function (containerId, filePath) { // 状态变量 const state = { originalContent: '', editor: null }; // DOM元素 const dom = { monacoContainer: document.getElementById(`${containerId}-monaco-editor`), reloadBtn: document.getElementById(`${containerId}-reload`), saveBtn: document.getElementById(`${containerId}-save`), lineWrapCheck: document.getElementById(`${containerId}-line-wrap`), fileSizeEl: document.getElementById(`${containerId}-size`), fileModifiedEl: document.getElementById(`${containerId}-modified`), editorLanguageSelect: document.getElementById(`${containerId}-editor-language`), fontSizeSelect: document.getElementById(`${containerId}-font-size`) }; // 初始化方法 async function init() { loadFileData(); initEventListeners(); } // 核心功能方法 async function loadFileData() { try { dom.monacoContainer.innerHTML = `

正在加载文件内容...

`; const response = await fetch(`/file/api/content?path=${encodeURIComponent(filePath)}`); await FileViewerUtils.handleResponse(response, async data => { updateFileInfo(data.info); let fileContent = data.content; if (data.info && data.info.total_lines && data.content.length < data.info.total_lines) { dom.monacoContainer.innerHTML = `

正在加载大文件,请稍候...

`; const totalLines = data.info.total_lines; let completeContent = [...fileContent]; let currentLine = fileContent.length; while (currentLine < totalLines) { const nextResponse = await fetch( `/file/api/content?path=${encodeURIComponent(filePath)}&start=${currentLine}` ); const nextData = await nextResponse.json(); if (nextData.content && nextData.content.length > 0) { completeContent = completeContent.concat(nextData.content); currentLine += nextData.content.length; } else { break; } } fileContent = completeContent; } initMonacoEditor(fileContent.join('\n')); }); } catch (error) { FileViewerUtils.handleError('文件加载失败', error); dom.monacoContainer.innerHTML = `
加载失败: ${error.message}
`; } } async function saveFileContent() { try { const content = state.editor.getValue(); dom.saveBtn.innerHTML = ' 保存中...'; dom.saveBtn.disabled = true; const response = await fetch('/file/api/save', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ path: filePath, content: content }) }); await FileViewerUtils.handleResponse(response, data => { if (data.success) { alert('文件保存成功'); state.originalContent = content; } else { throw new Error(data.error || '保存失败'); } }); } catch (error) { FileViewerUtils.handleError('保存文件失败', error); } finally { dom.saveBtn.innerHTML = ' 保存'; dom.saveBtn.disabled = false; } } // Monaco编辑器初始化 function initMonacoEditor(content) { // 只有在全局没有配置过Monaco时才进行配置 if (!window.monacoConfigured) { require.config({paths: {'vs': 'https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs'}}); window.monacoConfigured = true; } // 如果Monaco已加载,直接创建编辑器 if (window.monaco) { createEditor(content); return; } // 否则加载Monaco require(['vs/editor/editor.main'], function () { createEditor(content); }); } // 创建编辑器 function createEditor(content) { const language = FileViewerUtils.guessLanguage(filePath); if (dom.editorLanguageSelect) { if (dom.editorLanguageSelect.querySelector(`option[value="${language}"]`)) { dom.editorLanguageSelect.value = language; } } const fontSize = dom.fontSizeSelect ? parseInt(dom.fontSizeSelect.value, 10) : 16; dom.monacoContainer.innerHTML = ''; state.editor = monaco.editor.create(dom.monacoContainer, { value: content, language: language, theme: 'vs-dark', automaticLayout: true, minimap: {enabled: true}, scrollBeyondLastLine: false, lineNumbers: 'on', renderLineHighlight: 'all', wordWrap: dom.lineWrapCheck && dom.lineWrapCheck.checked ? 'on' : 'off', fontSize: fontSize, tabSize: 4, insertSpaces: true, fontFamily: "'JetBrains Mono', 'Source Code Pro', 'Menlo', 'Ubuntu Mono', 'Fira Code', monospace", fontLigatures: true, letterSpacing: 0.5 }); state.originalContent = content; } // 事件处理 function initEventListeners() { if (dom.reloadBtn) { dom.reloadBtn.addEventListener('click', function () { if (state.editor && state.editor.getValue() !== state.originalContent) { if (confirm('您有未保存的更改,确定要刷新吗?')) { loadFileData(); } } else { loadFileData(); } }); } if (dom.saveBtn) { dom.saveBtn.addEventListener('click', function () { saveFileContent(); }); } if (dom.editorLanguageSelect) { dom.editorLanguageSelect.addEventListener('change', function () { if (state.editor) { monaco.editor.setModelLanguage( state.editor.getModel(), this.value ); } }); } if (dom.fontSizeSelect) { dom.fontSizeSelect.addEventListener('change', function () { if (state.editor) { const fontSize = parseInt(this.value, 10); state.editor.updateOptions({fontSize: fontSize}); } }); } if (dom.lineWrapCheck) { dom.lineWrapCheck.addEventListener('change', function () { if (state.editor) { state.editor.updateOptions({wordWrap: this.checked ? 'on' : 'off'}); } }); } } // 更新文件信息 function updateFileInfo(info) { if (!info || typeof info !== 'object') { console.warn('缺少文件信息数据'); info = {size: null, modified: null}; } if (dom.fileSizeEl) { dom.fileSizeEl.textContent = FileViewerUtils.formatFileSize(info.size); } if (dom.fileModifiedEl) { dom.fileModifiedEl.textContent = FileViewerUtils.formatDateTime(info.modified); } const filePathElements = document.querySelectorAll('.file-path'); filePathElements.forEach(el => { if (el.textContent.trim() === '') { el.textContent = filePath || '未知'; } }); } // 初始化执行 init(); } })(); ================================================ FILE: WebUI/static/js/components/loading.js ================================================ class LoadingSpinner { constructor(options = {}) { this.options = { size: options.size || 'md', color: options.color || 'primary', text: options.text || '加载中...' }; } render() { const sizes = { "sm": "spinner-border-sm", "md": "", "lg": "spinner-border spinner-border-lg" }; return `
${this.options.text}
${this.options.text ? `${this.options.text}` : ''}
`; } } class ProgressBar { constructor(options = {}) { this.options = { value: options.value || 0, color: options.color || 'primary', striped: options.striped !== undefined ? options.striped : true, animated: options.animated !== undefined ? options.animated : true, label: options.label !== undefined ? options.label : true }; } render() { return `
${this.options.label ? `${this.options.value}%` : ''}
`; } update(value) { this.options.value = value; return this.render(); } } class FullPageLoader { constructor(options = {}) { this.options = { message: options.message || '页面加载中...', id: options.id || 'fullPageLoader' }; this.element = null; } show() { if (!this.element) { this.element = document.createElement('div'); this.element.id = this.options.id; this.element.className = 'full-page-loader'; this.element.innerHTML = `
加载中...
${this.options.message ? `

${this.options.message}

` : ''}
`; document.body.appendChild(this.element); } this.element.style.display = 'flex'; } hide() { if (this.element) { this.element.style.display = 'none'; } } setMessage(message) { this.options.message = message; if (this.element) { const messageElement = this.element.querySelector('p'); if (messageElement) { messageElement.textContent = message; } } } } class ContentLoader { constructor(options = {}) { this.options = { size: options.size || 'md', containerClass: options.containerClass || '' }; } render() { let skeletonLines = ''; switch (this.options.size) { case 'sm': skeletonLines = `
`; break; case 'lg': skeletonLines = `
`; break; default: skeletonLines = `
`; } return `
${skeletonLines}
`; } } // 导出组件类 window.LoadingSpinner = LoadingSpinner; window.ProgressBar = ProgressBar; window.FullPageLoader = FullPageLoader; window.ContentLoader = ContentLoader; ================================================ FILE: WebUI/static/js/components/modals.js ================================================ class BaseModal { constructor(options = {}) { this.options = { id: options.id || 'modal', title: options.title || '', size: options.size || 'md', backdrop: options.backdrop !== undefined ? options.backdrop : true, keyboard: options.keyboard !== undefined ? options.keyboard : true }; this.modal = null; } createModal() { const modalHtml = ` `; const modalElement = document.createElement('div'); modalElement.innerHTML = modalHtml; document.body.appendChild(modalElement.firstElementChild); this.modal = new bootstrap.Modal(document.getElementById(this.options.id), { backdrop: this.options.backdrop, keyboard: this.options.keyboard }); } getModalBody() { return ''; } getModalFooter() { return ''; } show() { if (!this.modal) { this.createModal(); } this.modal.show(); } hide() { if (this.modal) { this.modal.hide(); } } dispose() { if (this.modal) { this.modal.dispose(); document.getElementById(this.options.id).remove(); this.modal = null; } } } class ConfirmModal extends BaseModal { constructor(options = {}) { super(options); this.options.message = options.message || '您确定要执行此操作吗?'; this.options.confirmText = options.confirmText || '确认'; this.options.cancelText = options.cancelText || '取消'; this.options.onConfirm = options.onConfirm || (() => { }); this.options.onCancel = options.onCancel || (() => { }); } getModalBody() { return `

${this.options.message}

`; } getModalFooter() { return ` `; } createModal() { super.createModal(); document.getElementById(`${this.options.id}-confirm`).addEventListener('click', () => { this.options.onConfirm(); this.hide(); }); } } class FormModal extends BaseModal { constructor(options = {}) { super(options); this.options.saveText = options.saveText || '保存'; this.options.cancelText = options.cancelText || '取消'; this.options.onSave = options.onSave || (() => { }); this.options.formContent = options.formContent || ''; } getModalBody() { return ` `; } getModalFooter() { return ` `; } createModal() { super.createModal(); document.getElementById(`${this.options.id}-save`).addEventListener('click', () => { const form = document.getElementById(`${this.options.id}-form`); if (form.checkValidity()) { this.options.onSave(new FormData(form)); this.hide(); } else { form.reportValidity(); } }); } } class AjaxModal extends BaseModal { constructor(options = {}) { super(options); this.options.url = options.url || ''; this.options.method = options.method || 'GET'; this.options.data = options.data || null; } async loadContent() { try { const response = await fetch(this.options.url, { method: this.options.method, headers: { 'Content-Type': 'application/json' }, body: this.options.data ? JSON.stringify(this.options.data) : null }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const content = await response.text(); document.querySelector(`#${this.options.id} .modal-body`).innerHTML = content; } catch (error) { document.querySelector(`#${this.options.id} .modal-body`).innerHTML = `
加载失败: ${error.message}
`; } } getModalBody() { return ` `; } show() { super.show(); this.loadContent(); } } // 导出模态框类 window.BaseModal = BaseModal; window.ConfirmModal = ConfirmModal; window.FormModal = FormModal; window.AjaxModal = AjaxModal; ================================================ FILE: WebUI/static/js/components/notification.js ================================================ const NotificationManager = { /** * 显示通知 * @param {string} message - 通知消息 * @param {string} type - 通知类型: 'info', 'success', 'warning', 'error' * @param {number} duration - 通知显示持续时间(毫秒) */ show: function (message, type = 'info', duration = 3000) { let container = document.getElementById('notificationContainer'); // 确保容器唯一性 if (!container) { container = document.createElement('div'); container.id = 'notificationContainer'; document.body.appendChild(container); } // 类型映射 - 确保使用正确的CSS类 let cssType = 'info'; switch (type) { case 'success': cssType = 'success'; break; case 'error': case 'danger': cssType = 'error'; break; case 'warning': cssType = 'warning'; break; default: cssType = 'info'; } // 创建纯净通知元素 const notification = document.createElement('div'); notification.className = `pure-notification ${cssType}-notification`; // 使用文本节点避免HTML解析 const textNode = document.createTextNode(message); notification.appendChild(textNode); container.appendChild(notification); // 强制布局刷新 notification.offsetHeight; // 显示动画 - 添加show类并设置内联样式 notification.classList.add('show'); notification.style.opacity = '1'; notification.style.transform = 'translateY(0)'; // 自动移除 setTimeout(() => { notification.classList.remove('show'); notification.style.opacity = '0'; notification.style.transform = 'translateY(-20px)'; setTimeout(() => { if (notification.parentNode) { container.removeChild(notification); } }, 300); }, duration); } }; // 导出到全局作用域 window.NotificationManager = NotificationManager; ================================================ FILE: WebUI/static/js/logs.js ================================================ async function loadInitialLogs(limit = 100) { try { const response = await fetch('/logs/api/recent?limit=' + limit); const logs = await response.json(); displayLogs(logs); } catch (error) { console.error('加载日志失败:', error); } } // 定时获取新日志 function startLogUpdates(interval = 2000) { return setInterval(async () => { try { const response = await fetch('/logs/api/new'); const newLogs = await response.json(); if (newLogs && newLogs.length > 0) { appendLogs(newLogs); } } catch (error) { console.error('获取新日志失败:', error); } }, interval); } // 显示日志 function displayLogs(logs) { const logContainer = document.getElementById('log-container'); if (!logContainer) return; logContainer.innerHTML = ''; logs.forEach(log => { appendLogLine(log); }); scrollToBottom(); } // 添加新日志 function appendLogs(logs) { logs.forEach(log => { appendLogLine(log); }); scrollToBottom(); } // 添加单行日志 function appendLogLine(log) { const logContainer = document.getElementById('log-container'); if (!logContainer) return; const logLine = document.createElement('div'); logLine.className = 'log-line'; logLine.textContent = log; logContainer.appendChild(logLine); // 尝试根据日志内容添加级别样式 applyLogLevelStyle(logLine, log); } // 应用日志级别样式 function applyLogLevelStyle(logElement, logText) { if (!logElement || !logText) return; logText = logText.toLowerCase(); if (logText.includes('debug')) { logElement.classList.add('log-debug'); } else if (logText.includes('info')) { logElement.classList.add('log-info'); } else if (logText.includes('warning') || logText.includes('warn')) { logElement.classList.add('log-warning'); } else if (logText.includes('error')) { logElement.classList.add('log-error'); } else if (logText.includes('critical') || logText.includes('fatal')) { logElement.classList.add('log-critical'); } } // 滚动到底部 function scrollToBottom() { const logContainer = document.getElementById('log-container'); if (!logContainer) return; const autoScroll = document.getElementById('auto-scroll'); if (!autoScroll || autoScroll.checked) { logContainer.scrollTop = logContainer.scrollHeight; } } // 页面加载完成时初始化实时日志 function initRealtimeLog() { // 检查当前页面是否包含实时日志容器 const realtimeLogContainer = document.getElementById('realtime-log-container'); if (realtimeLogContainer) { loadInitialLogs(); const updateInterval = startLogUpdates(); // 页面关闭时清除定时器 window.addEventListener('beforeunload', () => { clearInterval(updateInterval); }); } } // 阻止日志滚动事件传播 function preventScrollPropagation() { const logContainers = document.querySelectorAll('.log-container, .log-viewer'); logContainers.forEach(container => { if (container) { container.addEventListener('wheel', function (e) { // 如果容器已滚动到底部或顶部,则不阻止传播 const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 1; const isAtTop = container.scrollTop <= 0; if (e.deltaY > 0 && !isAtBottom) { // 向下滚动且不在底部 e.stopPropagation(); } else if (e.deltaY < 0 && !isAtTop) { // 向上滚动且不在顶部 e.stopPropagation(); } }); } }); } // 页面加载完成时初始化 document.addEventListener('DOMContentLoaded', () => { initRealtimeLog(); preventScrollPropagation(); // 检查是否需要为非实时页面的日志查看器添加样式 const logViewers = document.querySelectorAll('.log-viewer'); logViewers.forEach(viewer => { const logLines = viewer.querySelectorAll('pre'); logLines.forEach(line => { // 为已存在的pre标签添加样式 if (line.parentNode === viewer) { const lineContent = line.textContent; const lines = lineContent.split('\n'); if (lines.length > 1) { line.remove(); lines.forEach(text => { if (text.trim() !== '') { const logLine = document.createElement('div'); logLine.className = 'log-line'; logLine.textContent = text; viewer.appendChild(logLine); applyLogLevelStyle(logLine, text); } }); } } }); }); }); ================================================ FILE: WebUI/static/js/main.js ================================================ document.addEventListener('DOMContentLoaded', function () { // 激活当前页面的侧边栏菜单 const path = window.location.pathname; document.querySelectorAll('.sidebar .nav-link').forEach(link => { const href = link.getAttribute('href'); if (href !== '/' && path.includes(href)) { link.classList.add('active'); } else if (href === '/' && path === '/') { link.classList.add('active'); } }); // 禁用侧边栏的鼠标滚轮事件 const sidebar = document.querySelector('.sidebar'); if (sidebar) { sidebar.addEventListener('wheel', (e) => { e.preventDefault(); e.stopPropagation(); return false; }); } // 添加退出登录确认 const logoutBtn = document.getElementById('logoutBtn'); if (logoutBtn) { logoutBtn.addEventListener('click', function (e) { e.preventDefault(); confirmAction('确定要退出登录吗?', function () { window.location.href = '/auth/logout'; }); }); } // 初始化Bootstrap工具提示 var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); tooltipTriggerList.map(function (tooltipTriggerEl) { return new bootstrap.Tooltip(tooltipTriggerEl); }); // 初始化Bootstrap下拉菜单 var dropdownElementList = [].slice.call(document.querySelectorAll('.dropdown-toggle')); dropdownElementList.map(function (dropdownToggleEl) { return new bootstrap.Dropdown(dropdownToggleEl); }); // 在这里添加其他全局JavaScript功能 }); // 显示通知 function showNotification(message, type = 'info') { // 使用通知管理器 if (window.NotificationManager) { NotificationManager.show(message, type); return; } // 如果通知管理器不可用,使用备用实现 let container = document.getElementById('notificationContainer'); // 确保容器唯一性 if (!container) { container = document.createElement('div'); container.id = 'notificationContainer'; document.body.appendChild(container); } // 类型映射 - 确保使用正确的CSS类 let cssType = 'info'; switch (type) { case 'success': cssType = 'success'; break; case 'error': case 'danger': cssType = 'error'; break; case 'warning': cssType = 'warning'; break; default: cssType = 'info'; } // 创建纯净通知元素 const notification = document.createElement('div'); notification.className = `pure-notification ${cssType}-notification`; // 使用文本节点避免HTML解析 const textNode = document.createTextNode(message); notification.appendChild(textNode); container.appendChild(notification); // 强制布局刷新 notification.offsetHeight; // 显示动画 - 添加show类并设置内联样式 notification.classList.add('show'); notification.style.opacity = '1'; notification.style.transform = 'translateY(0)'; // 自动移除 setTimeout(() => { notification.classList.remove('show'); notification.style.opacity = '0'; notification.style.transform = 'translateY(-20px)'; setTimeout(() => { if (notification.parentNode) { container.removeChild(notification); } }, 300); }, 3000); } // 格式化日期时间 function formatDateTime(timestamp) { if (!timestamp) return ''; const date = new Date(timestamp * 1000); return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); } // 显示确认对话框 function confirmAction(message, callback) { if (confirm(message)) { callback(); } } // 防抖函数 function debounce(func, wait) { let timeout; return function () { const context = this; const args = arguments; clearTimeout(timeout); timeout = setTimeout(() => func.apply(context, args), wait); }; } // 节流函数 function throttle(func, wait) { let lastExec = 0; return function () { const context = this; const args = arguments; const now = Date.now(); if (now - lastExec >= wait) { func.apply(context, args); lastExec = now; } }; } // 导出全局函数 window.showNotification = showNotification; window.formatDateTime = formatDateTime; window.confirmAction = confirmAction; window.debounce = debounce; window.throttle = throttle; ================================================ FILE: WebUI/static/js/pages/about.js ================================================ document.addEventListener('DOMContentLoaded', function () { // 检查最新版本 checkLatestVersion(); // 添加平滑滚动效果 document.querySelectorAll('a[href^="#"]').forEach(anchor => { anchor.addEventListener('click', function (e) { e.preventDefault(); const target = document.querySelector(this.getAttribute('href')); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); }); }); // 检查最新版本 function checkLatestVersion() { const currentVersion = document.getElementById('currentVersion'); const latestVersion = document.getElementById('latestVersion'); const versionStatus = document.getElementById('versionStatus'); if (!currentVersion || !latestVersion || !versionStatus) return; fetch('/about/api/version') .then(response => response.json()) .then(data => { if (data.latest_version) { latestVersion.textContent = data.latest_version; // 比较版本 if (data.latest_version === currentVersion.textContent) { versionStatus.className = 'badge bg-success'; versionStatus.textContent = '已是最新版本'; } else { versionStatus.className = 'badge bg-warning'; versionStatus.textContent = '有新版本可用'; } } }) .catch(error => { console.error('检查版本出错:', error); versionStatus.className = 'badge bg-secondary'; versionStatus.textContent = '版本检查失败'; }); } // 复制文本到剪贴板 function copyToClipboard(text) { const textarea = document.createElement('textarea'); textarea.value = text; document.body.appendChild(textarea); textarea.select(); try { document.execCommand('copy'); showToast('复制成功!'); } catch (err) { console.error('复制失败:', err); showToast('复制失败,请手动复制'); } document.body.removeChild(textarea); } // 显示提示消息 function showToast(message) { const toast = document.createElement('div'); toast.className = 'toast align-items-center text-white bg-success'; toast.setAttribute('role', 'alert'); toast.setAttribute('aria-live', 'assertive'); toast.setAttribute('aria-atomic', 'true'); toast.innerHTML = `
${message}
`; const container = document.createElement('div'); container.className = 'toast-container position-fixed bottom-0 end-0 p-3'; container.appendChild(toast); document.body.appendChild(container); const bsToast = new bootstrap.Toast(toast); bsToast.show(); toast.addEventListener('hidden.bs.toast', () => { document.body.removeChild(container); }); } ================================================ FILE: WebUI/static/js/pages/auth.js ================================================ document.addEventListener('DOMContentLoaded', function () { // 添加登录页面专用类 document.body.classList.add('login-page'); // 处理表单验证 const form = document.querySelector('form'); if (form) { form.addEventListener('submit', function (event) { if (!form.checkValidity()) { event.preventDefault(); event.stopPropagation(); } form.classList.add('was-validated'); }); } // 处理记住我复选框 const rememberMe = document.querySelector('#remember_me'); if (rememberMe) { // 从localStorage读取之前的选择 const remembered = localStorage.getItem('remember_me') === 'true'; rememberMe.checked = remembered; // 保存选择到localStorage rememberMe.addEventListener('change', function () { localStorage.setItem('remember_me', this.checked); }); } // 处理用户名自动填充 const usernameInput = document.querySelector('#username'); if (usernameInput) { const savedUsername = localStorage.getItem('last_username'); if (savedUsername) { usernameInput.value = savedUsername; } // 保存用户名到localStorage form.addEventListener('submit', function () { if (rememberMe && rememberMe.checked) { localStorage.setItem('last_username', usernameInput.value); } else { localStorage.removeItem('last_username'); } }); } // 处理闪现消息 const messages = document.querySelectorAll('.flash-message'); messages.forEach(message => { const type = message.dataset.type || 'info'; const text = message.textContent; showNotification(text, type); message.remove(); }); }); ================================================ FILE: WebUI/static/js/pages/config.js ================================================ let isSaving = false; // 页面加载完成后执行 document.addEventListener('DOMContentLoaded', function () { // 加载配置schema和配置数据 loadConfigSchemas(); // 创建节流版本的保存函数,500毫秒内只能触发一次 const throttledSave = throttle(function () { // 如果已经在保存中,直接返回 if (isSaving) { showNotification('正在保存,请稍候...', 'warning'); return; } // 设置保存状态 isSaving = true; // 立即禁用按钮,防止多次点击 this.disabled = true; // 执行保存操作 saveAllConfigs(); }, 500); // 绑定保存按钮事件 const saveButton = document.getElementById('saveConfig'); if (saveButton) { saveButton.addEventListener('click', throttledSave); } // 绑定动态添加的元素事件 document.addEventListener('click', function (e) { // 处理添加数组项按钮点击 if (e.target.closest('.add-array-item')) { const button = e.target.closest('.add-array-item'); const fieldId = button.dataset.fieldId; const configName = button.dataset.config; const propPath = button.dataset.path; handleAddArrayItem(fieldId, configName, propPath); } // 处理删除数组项按钮点击 if (e.target.closest('.remove-array-item')) { const button = e.target.closest('.remove-array-item'); button.closest('.array-item').remove(); } // 处理配置折叠按钮点击 if (e.target.closest('.config-header')) { const header = e.target.closest('.config-header'); const icon = header.querySelector('i'); icon.classList.toggle('fa-chevron-down'); icon.classList.toggle('fa-chevron-up'); } }); }); // 加载配置schema async function loadConfigSchemas() { try { const response = await fetch('/config/api/schemas'); const data = await response.json(); if (data.code === 0 && data.data) { const schemas = data.data; const schemaKeys = Object.keys(schemas); if (schemaKeys.length === 0) { console.warn("Schema数据为空对象"); document.getElementById('configSections').innerHTML = '
没有可用的配置schema
'; return; } // 加载配置数据 await loadConfigs(schemas); } else { throw new Error(data.msg || '加载配置schema失败'); } } catch (error) { console.error('加载配置schema失败:', error); document.getElementById('configSections').innerHTML = `
加载配置schema失败: ${error.message}
`; } } // 加载配置数据 async function loadConfigs(schemas) { try { const response = await fetch('/config/api/config'); const data = await response.json(); if (data.code === 0 && data.data) { renderConfigForm(schemas, data.data); } else { throw new Error(data.msg || '加载配置数据失败'); } } catch (error) { console.error('加载配置数据失败:', error); document.getElementById('configSections').innerHTML = `
加载配置数据失败: ${error.message}
`; } } // 渲染配置表单 function renderConfigForm(schemas, configs) { const configSections = document.getElementById('configSections'); configSections.innerHTML = ''; // 获取配置节顺序 const sectionsOrder = schemas._meta && schemas._meta.sectionsOrder ? schemas._meta.sectionsOrder : Object.keys(schemas).filter(name => name !== '_meta'); console.log('按原始顺序渲染配置节:', sectionsOrder); // 删除元数据,防止渲染成配置节 delete schemas._meta; // 按原始顺序遍历配置节 for (const configName of sectionsOrder) { if (!schemas[configName]) continue; const schema = schemas[configName]; const config = configs[configName] || {}; const section = document.createElement('div'); section.className = 'config-section'; section.dataset.config = configName; section.innerHTML = `
${schema.title || configName}
${schema.description ? `

${schema.description}

` : ''}
${renderSchemaProperties(schema, config, configName)}
`; configSections.appendChild(section); } } // 渲染schema属性 function renderSchemaProperties(schema, config, configName, parentPath = '') { let html = ''; if (schema.properties) { // 获取属性按照配置文件中的原始顺序 const propertyOrder = schema.propertyOrder || Object.keys(schema.properties); // 按原始顺序遍历属性 for (const propName of propertyOrder) { // 跳过不存在的属性 if (!schema.properties[propName]) continue; const propSchema = schema.properties[propName]; const propValue = config[propName]; const propPath = parentPath ? `${parentPath}.${propName}` : propName; html += renderPropertyField(propName, propSchema, propValue, configName, propPath); } } return html; } // 渲染属性字段 function renderPropertyField(propName, propSchema, propValue, configName, propPath) { const fieldId = `${configName}-${propPath.replace(/\./g, '-')}`; const fieldName = `${configName}[${propPath}]`; const required = propSchema.required && propSchema.required.includes(propName); let fieldHtml = `
`; switch (propSchema.type) { case 'string': if (propSchema.enum) { fieldHtml += renderEnumField(fieldId, fieldName, propSchema, propValue, required); } else if (propSchema.format === 'password') { fieldHtml += renderPasswordField(fieldId, fieldName, propValue, required); } else if (propSchema.format === 'textarea') { fieldHtml += renderTextareaField(fieldId, fieldName, propValue, required); } else { fieldHtml += renderTextField(fieldId, fieldName, propValue, required); } break; case 'number': case 'integer': fieldHtml += renderNumberField(fieldId, fieldName, propSchema, propValue, required); break; case 'boolean': fieldHtml += renderBooleanField(fieldId, fieldName, propValue); break; case 'array': fieldHtml += renderArrayField(fieldId, propSchema, propValue || [], configName, propPath); break; case 'object': fieldHtml += renderObjectField(fieldId, propSchema, propValue || {}, configName, propPath); break; default: fieldHtml += `
不支持的类型: ${propSchema.type}
`; } if (propSchema.description) { fieldHtml += `${propSchema.description}`; } fieldHtml += '
'; return fieldHtml; } // 渲染枚举字段 function renderEnumField(fieldId, fieldName, schema, value, required) { return ` `; } // 渲染密码字段 function renderPasswordField(fieldId, fieldName, value, required) { return ` `; } // 渲染文本区域字段 function renderTextareaField(fieldId, fieldName, value, required) { return ` `; } // 渲染文本字段 function renderTextField(fieldId, fieldName, value, required) { return ` `; } // 渲染数字字段 function renderNumberField(fieldId, fieldName, schema, value, required) { return ` `; } // 渲染布尔字段 function renderBooleanField(fieldId, fieldName, value) { return `
`; } // 渲染数组字段 function renderArrayField(fieldId, schema, value, configName, propPath) { let html = `
`; // 确保value是有效的数组 const arrayValue = Array.isArray(value) ? value : []; console.log(`渲染数组字段 ${propPath},值:`, arrayValue); if (arrayValue.length > 0) { arrayValue.forEach((item, index) => { html += renderArrayItem(fieldId, schema, item, index, configName, propPath); }); } else { // 如果数组为空,添加一个空元素让用户有起点 html += renderArrayItem(fieldId, schema, "", 0, configName, propPath); } html += `
`; return html; } // 渲染数组项 function renderArrayItem(fieldId, schema, value, index, configName, propPath) { const itemId = `${fieldId}-item-${index}`; const itemName = `${configName}[${propPath}][${index}]`; let html = `
`; if (schema.items.type === 'string') { // 确保value是字符串,避免undefined或null const itemValue = value !== null && value !== undefined ? String(value) : ''; html += ` `; } else if (schema.items.type === 'number' || schema.items.type === 'integer') { // 确保value是数字,避免undefined或null const itemValue = value !== null && value !== undefined ? Number(value) : ''; html += ` `; } else if (schema.items.type === 'object') { html += `
${renderSchemaProperties(schema.items, value || {}, configName, `${propPath}[${index}]`)}
`; } html += `
`; return html; } // 渲染对象字段 function renderObjectField(fieldId, schema, value, configName, propPath) { return `
${renderSchemaProperties(schema, value, configName, propPath)}
`; } // 处理添加数组项 async function handleAddArrayItem(fieldId, configName, propPath) { try { const container = document.querySelector(`#${fieldId}-container .array-items`); const index = container.children.length; const response = await fetch('/config/api/schemas'); const data = await response.json(); if (data.code === 0 && data.data) { const schemas = data.data; const schema = schemas[configName]; if (!schema) { throw new Error(`未找到配置 '${configName}' 的schema`); } // 找到数组的schema const pathParts = propPath.split('.'); let currentSchema = schema; for (const part of pathParts) { if (currentSchema.properties && currentSchema.properties[part]) { currentSchema = currentSchema.properties[part]; } else { throw new Error(`在路径 '${propPath}' 中未找到部分 '${part}' 的schema`); } } if (currentSchema.type !== 'array') { throw new Error(`路径 '${propPath}' 的schema不是数组类型`); } if (!currentSchema.items) { throw new Error('数组schema缺少items定义'); } const newItem = renderArrayItem(fieldId, currentSchema, null, index, configName, propPath); container.insertAdjacentHTML('beforeend', newItem); } else { throw new Error(data.msg || '获取schema失败'); } } catch (error) { console.error('添加数组项失败:', error); showNotification(error.message, 'error'); } } // 保存所有配置 async function saveAllConfigs() { const configs = {}; const saveButton = document.getElementById('saveConfig'); const originalBtnText = saveButton.innerHTML; try { // 验证表单 if (!validateForm()) { saveButton.disabled = false; saveButton.innerHTML = originalBtnText; isSaving = false; return; } // 收集所有配置 document.querySelectorAll('.config-section').forEach(section => { const configName = section.dataset.config; configs[configName] = collectConfigData(configName); }); const configNames = Object.keys(configs); let failedConfigs = 0; // 逐个保存配置 for (let i = 0; i < configNames.length; i++) { const configName = configNames[i]; const progress = Math.round((i / configNames.length) * 100); saveButton.innerHTML = ` 保存中 ${progress}%`; try { const success = await saveConfig(configName, configs[configName]); if (!success) failedConfigs++; } catch (error) { console.error(`保存配置 ${configName} 失败:`, error); failedConfigs++; } } // 显示保存结果 if (failedConfigs > 0) { showNotification(`保存完成,但有 ${failedConfigs} 个配置保存失败`, 'error'); } else { showNotification('所有配置保存成功', 'success'); } } catch (error) { console.error('保存配置失败:', error); showNotification('保存配置失败: ' + error.message, 'error'); } finally { saveButton.disabled = false; saveButton.innerHTML = originalBtnText; isSaving = false; } } // 验证表单 function validateForm() { const requiredInputs = document.querySelectorAll('input[required], select[required], textarea[required]'); let isValid = true; let firstInvalidElement = null; // 清除之前的验证错误提示 document.querySelectorAll('.invalid-feedback').forEach(el => el.remove()); document.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid')); // 验证每个必填字段 requiredInputs.forEach(input => { const value = input.value; if (!value || value.trim() === '') { isValid = false; input.classList.add('is-invalid'); const feedback = document.createElement('div'); feedback.className = 'invalid-feedback'; feedback.textContent = '此字段不能为空'; input.parentNode.appendChild(feedback); if (!firstInvalidElement) { firstInvalidElement = input; } } }); // 滚动到第一个错误字段 if (!isValid && firstInvalidElement) { firstInvalidElement.scrollIntoView({behavior: 'smooth', block: 'center'}); showNotification('表单验证失败,请检查必填字段', 'error'); } return isValid; } // 收集配置数据 function collectConfigData(configName) { const config = {}; const sectionElement = document.querySelector(`.config-section[data-config="${configName}"]`); // 处理基本输入字段 sectionElement.querySelectorAll('input[type="text"], input[type="number"], input[type="password"], select, textarea').forEach(input => { if (!input.name || !input.name.startsWith(`${configName}[`)) return; const path = input.name.match(/\[(.*?)\]/)[1]; let value = input.value; // 处理数字类型 if (input.type === 'number') { value = value ? Number(value) : null; } setNestedProperty(config, path, value); }); // 处理复选框 sectionElement.querySelectorAll('input[type="checkbox"]').forEach(input => { if (!input.name || !input.name.startsWith(`${configName}[`)) return; const path = input.name.match(/\[(.*?)\]/)[1]; const value = input.checked; setNestedProperty(config, path, value); }); // 处理数组类型 sectionElement.querySelectorAll('.array-container').forEach(container => { const fieldId = container.id.replace('-container', ''); const fieldMatch = fieldId.match(new RegExp(`${configName}-(.+)`)); if (!fieldMatch) return; const path = fieldMatch[1].replace(/-/g, '.'); const arrayItems = []; // 收集数组中的所有项 container.querySelectorAll('.array-item').forEach(item => { const input = item.querySelector('input, select, textarea'); if (input) { let value = input.value; if (input.type === 'number') { value = value ? Number(value) : null; } arrayItems.push(value); } }); // 设置数组值 setNestedProperty(config, path, arrayItems); }); console.log(`收集的配置数据 ${configName}:`, config); return config; } // 设置嵌套属性 function setNestedProperty(obj, path, value) { // 如果路径中包含连字符,直接设置为字段名,不再拆分为嵌套路径 if (path.includes('-')) { obj[path] = value; return; } const parts = path.split('.'); let current = obj; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; if (current[part] === undefined) { // 检查下一部分是否是数字,决定创建对象还是数组 const nextPart = parts[i + 1]; current[part] = !isNaN(parseInt(nextPart)) ? [] : {}; } current = current[part]; } const lastPart = parts[parts.length - 1]; current[lastPart] = value; } // 保存单个配置 async function saveConfig(configName, configData) { console.log(`发送配置数据 ${configName}:`, JSON.stringify(configData, null, 2)); try { const response = await fetch(`/config/api/config/${configName}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(configData) }); const data = await response.json(); if (data.code !== 0) { console.error(`保存配置 ${configName} 失败:`, data.msg); showNotification(`保存配置 ${configName} 失败: ${data.msg}`, 'error'); return false; } return true; } catch (error) { console.error(`保存配置 ${configName} 失败:`, error); showNotification(`保存配置 ${configName} 失败: ${error.message}`, 'error'); return false; } } ================================================ FILE: WebUI/static/js/pages/explorer.js ================================================ document.addEventListener('DOMContentLoaded', function () { // 初始化文件浏览器 initFileBrowser('file-explorer', window.initialPath || ''); // 处理视图切换 const viewToggleBtn = document.getElementById('view-toggle'); if (viewToggleBtn) { viewToggleBtn.addEventListener('click', function () { const currentView = localStorage.getItem('explorer-view') || 'list'; const newView = currentView === 'list' ? 'grid' : 'list'; // 保存视图选择 localStorage.setItem('explorer-view', newView); // 更新视图 updateExplorerView(newView); }); // 初始化视图 const savedView = localStorage.getItem('explorer-view') || 'list'; updateExplorerView(savedView); } // 更新视图显示 function updateExplorerView(view) { const container = document.querySelector('.file-browser-container'); if (!container) return; if (view === 'grid') { container.classList.add('view-grid'); container.classList.remove('view-list'); viewToggleBtn.innerHTML = ''; } else { container.classList.add('view-list'); container.classList.remove('view-grid'); viewToggleBtn.innerHTML = ''; } } // 处理文件操作 function handleFileOperation(operation, path) { switch (operation) { case 'open': if (isImageFile(path)) { previewImage(path); } else if (isTextFile(path)) { openFileViewer(path); } else { downloadFile(path); } break; case 'download': downloadFile(path); break; case 'delete': deleteFile(path); break; } } // 检查文件类型 function isImageFile(path) { const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']; return imageExtensions.some(ext => path.toLowerCase().endsWith(ext)); } function isTextFile(path) { const textExtensions = ['.txt', '.log', '.md', '.json', '.yml', '.yaml', '.toml', '.ini', '.conf']; return textExtensions.some(ext => path.toLowerCase().endsWith(ext)); } // 预览图片 function previewImage(path) { const modal = new ImagePreviewModal({ title: '图片预览', imagePath: path }); modal.show(); } // 打开文件查看器 function openFileViewer(path) { window.location.href = `/explorer/view/${encodeURIComponent(path)}`; } // 下载文件 function downloadFile(path) { window.location.href = `/file/download?path=${encodeURIComponent(path)}`; } // 删除文件 function deleteFile(path) { const modal = new ConfirmModal({ title: '确认删除', message: `确定要删除 ${path} 吗?此操作不可恢复。`, confirmText: '删除', cancelText: '取消', onConfirm: async () => { try { const response = await fetch('/file/api/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({path}) }); const result = await response.json(); if (result.success) { showNotification('文件删除成功', 'success'); // 重新加载文件列表 const refreshBtn = document.getElementById('file-explorer-refresh'); if (refreshBtn) { refreshBtn.click(); } } else { showNotification(result.message || '删除失败', 'error'); } } catch (error) { console.error('删除文件失败:', error); showNotification('删除文件失败', 'error'); } } }); modal.show(); } }); // 图片预览模态框类 class ImagePreviewModal extends BaseModal { constructor(options = {}) { super({ ...options, size: 'lg' }); this.imagePath = options.imagePath; } getModalBody() { return `
图片预览
`; } getModalFooter() { return ` `; } } ================================================ FILE: WebUI/static/js/pages/overview.js ================================================ let autoRefreshTimer = null; let previousStatus = null; // 保存上一次的状态数据 let socket; let logViewer; // 全局日志查看器元素 let notificationContainer; // 全局通知容器元素 // 页面加载完成后执行 document.addEventListener('DOMContentLoaded', function () { // 初始化变量 - 获取DOM元素 const refreshBtn = document.getElementById('refreshBtn'); const startBtn = document.getElementById('startBtn'); const stopBtn = document.getElementById('stopBtn'); const restartBtn = document.getElementById('restartBtn'); // 全局元素 logViewer = document.getElementById('logViewer'); notificationContainer = document.getElementById('notificationContainer'); // 防止日志滚动传播到页面 if (logViewer) { logViewer.addEventListener('wheel', function (e) { if (e.deltaY !== 0) { e.stopPropagation(); } }); } // 绑定事件 if (refreshBtn) { refreshBtn.addEventListener('click', refreshAllData); } if (startBtn) { startBtn.addEventListener('click', handleBotStart); } if (stopBtn) { stopBtn.addEventListener('click', handleBotStop); } if (restartBtn) { restartBtn.addEventListener('click', handleBotRestart); } // 加载所有数据 refreshAllData(); // 初始化WebSocket连接 initWebSocket(); // 启动自动刷新 startAutoRefresh(); // 页面关闭时清理 window.addEventListener('beforeunload', function () { stopAutoRefresh(); if (socket && socket.connected) { socket.disconnect(); } }); }); // 初始化WebSocket连接 function initWebSocket() { try { // 确保logViewer已存在 if (!logViewer) { logViewer = document.getElementById('logViewer'); if (!logViewer) { return; } } // 显示连接中状态 logViewer.innerHTML = '
连接中...

正在连接到日志服务器...

'; // 连接WebSocket,指定明确的选项 const serverUrl = window.location.protocol + '//' + window.location.host; socket = io(serverUrl, { transports: ['websocket', 'polling'], reconnectionAttempts: 5, reconnectionDelay: 1000, timeout: 5000 }); // 连接建立事件 socket.on('connect', function () { // 移除加载中状态 logViewer.innerHTML = '
正在请求日志...
'; // 连接后请求初始日志 socket.emit('request_logs', {n: 100}); }); // 断开连接事件 socket.on('disconnect', function () { if (logViewer) { logViewer.innerHTML = '
与服务器的连接已断开
'; } }); // 接收日志响应 socket.on('logs_response', function (data) { if (data && data.logs) { displayLogs(data.logs); } else { if (logViewer) { logViewer.innerHTML = '
收到的日志数据无效
'; } } }); // 新日志事件 socket.on('new_logs', function (data) { if (data && data.logs) { appendLogs(data.logs); } }); // 连接错误事件 socket.on('connect_error', function (error) { if (logViewer) { logViewer.innerHTML = '
连接服务器失败
'; } showNotification('日志连接失败,将在稍后重试...', 'warning'); }); } catch (error) { if (logViewer) { logViewer.innerHTML = '
初始化日志连接失败
'; } } } // 显示初始日志 function displayLogs(logs) { if (!logViewer) { return; } try { // 清空现有内容 logViewer.innerHTML = ''; // 检查日志数据 if (!logs || !Array.isArray(logs) || logs.length === 0) { logViewer.innerHTML = '
暂无日志数据
'; return; } // 创建文档片段,提高性能 const fragment = document.createDocumentFragment(); // 处理每行日志 logs.forEach((log, index) => { // 创建日志行 const logLine = document.createElement('div'); logLine.className = 'log-line'; logLine.textContent = log; // 应用样式 applyLogLevelStyle(logLine, log); // 添加到文档片段 fragment.appendChild(logLine); }); // 一次性添加所有日志行到DOM logViewer.appendChild(fragment); // 滚动到底部 logViewer.scrollTop = logViewer.scrollHeight; } catch (error) { logViewer.innerHTML = '
显示日志时出错
'; } } // 追加新日志 function appendLogs(logs) { if (!logViewer) { return; } if (!logs || !Array.isArray(logs) || logs.length === 0) { return; } try { // 在更新前检查是否在底部 const isScrolledToBottom = logViewer.scrollHeight - logViewer.clientHeight <= logViewer.scrollTop + 5; // 创建文档片段 const fragment = document.createDocumentFragment(); logs.forEach(log => { // 创建日志行 const logLine = document.createElement('div'); logLine.className = 'log-line'; logLine.textContent = log; // 应用样式 applyLogLevelStyle(logLine, log); // 添加到文档片段 fragment.appendChild(logLine); }); // 添加到日志查看器 logViewer.appendChild(fragment); // 只有当之前在底部时才滚动到底部 if (isScrolledToBottom) { logViewer.scrollTop = logViewer.scrollHeight; } } catch (error) { // 出错时静默处理,不中断用户体验 } } // 应用日志级别样式 function applyLogLevelStyle(logElement, logText) { if (!logElement || !logText) return; if (logText.includes('DEBUG')) { logElement.classList.add('log-debug'); } else if (logText.includes('INFO')) { logElement.classList.add('log-info'); } else if (logText.includes('SUCCESS')) { logElement.classList.add('log-success'); } else if (logText.includes('WARNING')) { logElement.classList.add('log-warning'); } else if (logText.includes('ERROR')) { logElement.classList.add('log-error'); } else if (logText.includes('CRITICAL')) { logElement.classList.add('log-critical'); } else if (logText.includes('WEBUI')) { logElement.classList.add('log-webui'); } } // 刷新状态 function refreshStatus() { fetch('/overview/api/status') .then(response => response.json()) .then(status => { // 检查数据是否有变化,如果没有变化则不更新 if (previousStatus && JSON.stringify(previousStatus) === JSON.stringify(status)) { return; } // 保存当前数据用于下次比较 previousStatus = JSON.parse(JSON.stringify(status)); // 更新状态信息 updateStatusDisplay(status); updateControlButtons(status); // 更新指标数据 updateMetricsDisplay(status); }) .catch(error => { // 出错时静默处理 }); } // 更新状态显示 function updateStatusDisplay(status) { const statusIndicator = document.querySelector('tbody tr:first-child td span'); const pidCell = document.querySelector('tbody tr:nth-child(2) td'); const startTimeCell = document.querySelector('tbody tr:nth-child(3) td'); if (status.running) { statusIndicator.className = 'badge bg-success'; statusIndicator.textContent = '运行中'; } else { statusIndicator.className = 'badge bg-danger'; statusIndicator.textContent = '已停止'; } pidCell.textContent = status.pid || '无'; startTimeCell.textContent = status.start_time || '未启动'; } // 更新指标显示 function updateMetricsDisplay(status) { // 更新卡片上的指标数据 document.querySelectorAll('.status-card-value').forEach(function (element) { const metricType = element.dataset.metric; if (metricType && status[metricType] !== undefined) { element.textContent = status[metricType]; } }); // 更新头像和账号信息 const profilePicture = document.getElementById('profilePicture'); const nicknameElement = document.getElementById('botNickname'); const wxidElement = document.getElementById('botWxid'); const aliasElement = document.getElementById('botAlias'); if (profilePicture) profilePicture.innerHTML = status.avatar ? `QRCode` : ''; if (nicknameElement) nicknameElement.textContent = status.nickname || '未登陆'; if (wxidElement) wxidElement.textContent = status.wxid || '未登陆'; if (aliasElement) aliasElement.textContent = status.alias || '未登陆'; } // 更新控制按钮 function updateControlButtons(status) { const startBtn = document.getElementById('startBtn'); const stopBtn = document.getElementById('stopBtn'); const restartBtn = document.getElementById('restartBtn'); if (startBtn) { startBtn.style.display = status.running ? 'none' : 'inline-block'; } if (stopBtn) { stopBtn.style.display = status.running ? 'inline-block' : 'none'; } if (restartBtn) { restartBtn.style.display = status.running ? 'inline-block' : 'none'; } } // 处理机器人启动 function handleBotStart() { const btn = this; btn.disabled = true; fetch('/bot/api/start', {method: 'POST'}) .then(response => response.json()) .then(result => { btn.disabled = false; if (result.success) { showNotification(result.message, 'success'); refreshAllData(); } else { showNotification(result.message, 'danger'); } }) .catch(error => { btn.disabled = false; showNotification('启动机器人失败', 'danger'); }); } // 处理机器人停止 function handleBotStop() { const btn = this; btn.disabled = true; fetch('/bot/api/stop', {method: 'POST'}) .then(response => response.json()) .then(result => { btn.disabled = false; if (result.success) { showNotification(result.message, 'warning'); refreshAllData(); } else { showNotification(result.message, 'danger'); } }) .catch(error => { btn.disabled = false; showNotification('停止机器人失败', 'danger'); }); } // 处理机器人重启 function handleBotRestart() { const btn = this; btn.disabled = true; fetch('/bot/api/restart', {method: 'POST'}) .then(response => response.json()) .then(result => { btn.disabled = false; if (result.success) { showNotification(result.message, 'success'); refreshAllData(); } else { showNotification(result.message, 'danger'); } }) .catch(error => { btn.disabled = false; showNotification('重启机器人失败', 'danger'); }); } // 刷新所有数据 function refreshAllData() { refreshStatus(); } // 启动自动刷新 function startAutoRefresh() { stopAutoRefresh(); autoRefreshTimer = setInterval(refreshAllData, 10000); } // 停止自动刷新 function stopAutoRefresh() { if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; } } // 显示通知 function showNotification(message, type = 'info') { // 使用通知管理器 if (window.NotificationManager) { NotificationManager.show(message, type); return; } // 以下是备用实现,当通知管理器不可用时使用 let container = document.getElementById('notificationContainer'); // 确保容器唯一性 if (!container) { container = document.createElement('div'); container.id = 'notificationContainer'; document.body.appendChild(container); } // 类型映射 - 确保使用正确的CSS类 let cssType = 'info'; switch (type) { case 'success': cssType = 'success'; break; case 'error': case 'danger': cssType = 'error'; break; case 'warning': cssType = 'warning'; break; default: cssType = 'info'; } // 创建纯净通知元素 const notification = document.createElement('div'); notification.className = `pure-notification ${cssType}-notification`; // 使用文本节点避免HTML解析 const textNode = document.createTextNode(message); notification.appendChild(textNode); container.appendChild(notification); // 强制布局刷新 notification.offsetHeight; // 显示动画 - 添加show类并设置内联样式 notification.classList.add('show'); notification.style.opacity = '1'; notification.style.transform = 'translateY(0)'; // 自动移除 setTimeout(() => { notification.classList.remove('show'); notification.style.opacity = '0'; notification.style.transform = 'translateY(-20px)'; setTimeout(() => { if (notification.parentNode) { container.removeChild(notification); } }, 300); }, 3000); } ================================================ FILE: WebUI/static/js/pages/plugin.js ================================================ let currentPluginId = null; let jsonEditor = null; let botActive = false; let allPlugins = []; // 存储所有插件数据用于搜索 // 页面加载完成后执行 $(document).ready(function () { // 初始化检查 initializePluginManager(); // 绑定事件 bindEvents(); }); // 初始化插件管理器 function initializePluginManager() { checkBotStatus() .done((res) => { botActive = res?.running || false; if (!botActive) { $('.plugin-action-btn, .reload-btn') .tooltip('dispose') .attr('title', '请先启动机器人') .tooltip({trigger: 'hover'}); } }) .fail(() => { botActive = false; showNotification('机器人状态检查失败,部分功能受限', 'warning'); }) .always(loadPlugins); } // 绑定事件 function bindEvents() { // 刷新按钮点击事件 $('#refreshPlugins').click(loadPlugins); // 插件列表容器事件委托 $('#pluginListContainer') .on('click', '.plugin-action-btn', handlePluginActionClick) .on('click', '.reload-btn:not(:disabled)', handlePluginReloadClick) .on('click', '.config-btn', handleConfigButtonClick); // 模态框按钮事件 $('#enableDisablePlugin').click(handleEnableDisableClick); $('#reloadPlugin').click(handleReloadClick); $('#savePluginConfig').click(handleSaveConfigClick); // 搜索框输入事件 $('#pluginSearch').on('input', handlePluginSearch); } // 检查机器人状态 function checkBotStatus() { return $.ajax({ url: '/bot/api/status', type: 'GET' }); } // 加载插件列表 function loadPlugins() { $('#pluginListContainer').html('
正在加载插件列表...
'); return $.ajax({ url: '/plugin/api/list', type: 'GET', success: function (response) { if (response.code === 0) { allPlugins = response.data || []; renderPluginList(allPlugins); } else { showLoadError(response.msg); } }, error: function (xhr, status, error) { showLoadError(error); } }); } // 显示加载错误 function showLoadError(error) { $('#pluginListContainer').html(`
加载失败: ${error}
`); } // 渲染插件列表 function renderPluginList(plugins) { const container = $('#pluginListContainer'); container.empty(); if (!plugins || plugins.length === 0) { container.html('
没有可用的插件
'); return; } plugins.forEach(plugin => { plugin.id = plugin.id || plugin.name; const isEnabled = plugin.enabled; const card = $(`
${plugin.name}
v${plugin.version || '未知'}
${plugin.directory || '未知'} | ${plugin.author || '未知'} | ${isEnabled ? '已加载' : '已卸载'}

${plugin.description || '无描述信息'}

`); container.append(card); }); } // 处理插件操作按钮点击 function handlePluginActionClick() { const pluginId = $(this).data('id'); const action = $(this).data('action'); handlePluginAction(pluginId, action); } // 处理插件重载按钮点击 function handlePluginReloadClick() { const pluginId = $(this).data('id'); reloadPlugin(pluginId); } // 处理配置按钮点击 function handleConfigButtonClick() { const directory = $(this).data('directory'); window.location.href = `/explorer/?path=${encodeURIComponent(directory)}`; } // 处理启用/禁用按钮点击 function handleEnableDisableClick() { if (!currentPluginId) return; const action = $(this).text(); try { if (action === '加载') { enablePlugin(currentPluginId); } else { disablePlugin(currentPluginId); } $('#pluginDetailModal').modal('hide'); } catch (e) { showNotification('处理失败: ' + e.message, 'error'); } } // 处理重载按钮点击 function handleReloadClick() { if (!currentPluginId) return; reloadPlugin(currentPluginId); } // 处理保存配置按钮点击 function handleSaveConfigClick() { if (!currentPluginId || !jsonEditor) return; try { const config = jsonEditor.get(); savePluginConfig(currentPluginId, config); } catch (e) { showNotification('配置格式错误: ' + e.message, 'error'); } } // 处理插件搜索 function handlePluginSearch() { const keyword = $(this).val().toLowerCase(); const filtered = allPlugins.filter(plugin => { return ( plugin.name.toLowerCase().includes(keyword) || (plugin.author && plugin.author.toLowerCase().includes(keyword)) || (plugin.description && plugin.description.toLowerCase().includes(keyword)) ); }); renderPluginList(filtered); } // 处理插件操作 function handlePluginAction(pluginId, action) { const btn = $(`.plugin-action-btn[data-id="${pluginId}"]`); const apiUrl = `/plugin/api/${action === 'load' ? 'enable' : 'disable'}/${pluginId}`; btn.prop('disabled', true).html('处理中...'); $.ajax({ url: apiUrl, type: 'POST', success: (res) => { if (res.code === 0) { showNotification(`插件${action === 'load' ? '加载' : '卸载'}成功`, 'success'); loadPlugins(); } else { showNotification(res.msg, 'error'); } }, error: (xhr) => { showNotification(`操作失败: ${xhr.statusText}`, 'error'); }, complete: () => btn.prop('disabled', false) }); } // 启用插件 function enablePlugin(pluginId) { return $.ajax({ url: `/plugin/api/enable/${pluginId}`, type: 'POST' }); } // 禁用插件 function disablePlugin(pluginId) { return $.ajax({ url: `/plugin/api/disable/${pluginId}`, type: 'POST' }); } // 重新加载插件 function reloadPlugin(pluginId) { return $.ajax({ url: `/plugin/api/reload/${pluginId}`, type: 'POST' }); } // 保存插件配置 function savePluginConfig(pluginId, config) { return $.ajax({ url: `/plugin/api/config/${pluginId}`, type: 'POST', contentType: 'application/json', data: JSON.stringify(config) }); } // 显示通知 function showNotification(message, type = 'info') { // 使用通知管理器 if (window.NotificationManager) { NotificationManager.show(message, type); return; } // 以下是备用实现,当通知管理器不可用时使用 let container = document.getElementById('notificationContainer'); // 确保容器唯一性 if (!container) { container = document.createElement('div'); container.id = 'notificationContainer'; document.body.appendChild(container); } // 类型映射 - 确保使用正确的CSS类 let cssType = 'info'; switch (type) { case 'success': cssType = 'success'; break; case 'error': case 'danger': cssType = 'error'; break; case 'warning': cssType = 'warning'; break; default: cssType = 'info'; } // 创建纯净通知元素 const notification = document.createElement('div'); notification.className = `pure-notification ${cssType}-notification`; // 使用文本节点避免HTML解析 const textNode = document.createTextNode(message); notification.appendChild(textNode); container.appendChild(notification); // 强制布局刷新 notification.offsetHeight; // 显示动画 - 添加show类并设置内联样式 notification.classList.add('show'); notification.style.opacity = '1'; notification.style.transform = 'translateY(0)'; // 自动移除 setTimeout(() => { notification.classList.remove('show'); notification.style.opacity = '0'; notification.style.transform = 'translateY(-20px)'; setTimeout(() => { if (notification.parentNode) { container.removeChild(notification); } }, 300); }, 3000); } ================================================ FILE: WebUI/static/js/pages/tools.js ================================================ $(document).ready(function () { // 加载工具列表 loadTools(); }); // 加载工具列表 function loadTools() { $.ajax({ url: '/tools/api/list', type: 'GET', dataType: 'json', success: function (response) { if (response.code === 0) { renderTools(response.data); } else { showNotification('加载工具列表失败: ' + response.msg, 'danger'); $('#toolsContainer').html('
加载工具列表失败
'); } }, error: function (xhr, status, error) { showNotification('加载工具列表失败: ' + error, 'danger'); $('#toolsContainer').html('
加载工具列表失败
'); } }); } // 渲染工具列表 function renderTools(tools) { const toolsContainer = $('#toolsContainer'); if (!tools || tools.length === 0) { toolsContainer.html('
暂无可用工具
'); return; } let html = ''; tools.forEach(function (tool) { html += `
${tool.title}

${tool.description}

`; }); toolsContainer.html(html); // 绑定执行按钮事件 $('.execute-tool').click(function () { const toolId = $(this).data('tool-id'); const toolTitle = $(this).closest('.tool-card').find('.tool-title').text(); // 显示确认对话框 showConfirmModal('确认执行', `确定要执行"${toolTitle}"吗?`, function () { executeTool(toolId); }); }); } // 执行工具 function executeTool(toolId) { // 显示加载状态 const statusElement = $(`#status-${toolId}`); statusElement.removeClass('success error').addClass('loading'); statusElement.find('.status-text').text('正在执行中...'); statusElement.show(); $.ajax({ url: `/tools/api/execute/${toolId}`, type: 'POST', contentType: 'application/json', data: JSON.stringify({}), dataType: 'json', success: function (response) { if (response.code === 0) { // 更新状态为成功 statusElement.removeClass('loading error').addClass('success'); statusElement.find('.status-text').text(response.data.message || '执行成功'); // 显示执行详情 showExecutionDetail(true, response.data); } else { // 更新状态为失败 statusElement.removeClass('loading success').addClass('error'); statusElement.find('.status-text').text(response.msg); // 显示执行详情 showExecutionDetail(false, response.data); } }, error: function (xhr, status, error) { // 更新状态为失败 statusElement.removeClass('loading success').addClass('error'); statusElement.find('.status-text').text('执行失败: ' + error); // 显示执行详情 showExecutionDetail(false, {error: error}); } }); } // 显示执行详情 function showExecutionDetail(success, data) { const detailStatus = $('#detailExecutionStatus'); const detailStatusText = $('#detailStatusText'); const executionLog = $('#executionLog'); if (success) { detailStatus.removeClass('error loading').addClass('success'); detailStatusText.text(data.message || '执行成功'); } else { detailStatus.removeClass('success loading').addClass('error'); detailStatusText.text(data.error || '执行失败'); } // 显示执行日志 let logText = ''; if (data.stack) { logText += data.stack; } else if (typeof data === 'object') { logText = JSON.stringify(data, null, 2); } else { logText = String(data); } executionLog.text(logText); // 显示模态框 $('#executeDetailModal').modal('show'); } // 显示确认对话框 function showConfirmModal(title, message, callback) { $('#confirmModalLabel').text(title); $('#confirmModalBody').text(message); $('#confirmModalConfirm').off('click').on('click', function () { $('#confirmModal').modal('hide'); callback(); }); $('#confirmModal').modal('show'); } ================================================ FILE: WebUI/templates/about/index.html ================================================ {% extends "base.html" %} {% block title %}关于 - {{ app_name }}{% endblock %} {% block styles %} {{ super() }} {% endblock %} {% block content %}

XYBot V2

🤖 功能丰富的微信机器人框架

当前版本:V1.0.0

作者:HenryXiaoYang

访问 GitHub
{% endblock %} ================================================ FILE: WebUI/templates/auth/login.html ================================================ {% extends "base.html" %} {% block title %}登录 - {{ app_name }}{% endblock %} {% block styles %} {% endblock %} {% block content %} {% endblock %} {% block scripts %} {% endblock %} ================================================ FILE: WebUI/templates/base.html ================================================ {% block title %}{{ app_name }}{% endblock %} {% block styles %}{% endblock %}
{% block content %}{% endblock %}
2025 By HenryXiaoYang https://github.com/HenryXiaoYang/XYBotV2
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% endif %} {% endwith %} {% block scripts %}{% endblock %} ================================================ FILE: WebUI/templates/components/cards.html ================================================ {% macro status_card(title, value, icon="fa-tachometer-alt", color="primary", size="md", metric_type="") %}
{{ title }}

{{ value }}

{% endmacro %} {% macro control_card(title, status="stopped", start_url="#", stop_url="#", id="botControl") %}
{{ title }}

当前状态: {% if status == "running" %} 运行中 {% else %} 已停止 {% endif %}

{% endmacro %} {% macro log_card(title, id="logViewer", height="400px") %}
{{ title }}
正在加载日志...
{% endmacro %} ================================================ FILE: WebUI/templates/components/file_browser.html ================================================ {% macro file_browser(container_id='file-browser', initial_path='') %}
{% endmacro %} ================================================ FILE: WebUI/templates/components/file_viewer.html ================================================ {% macro file_viewer(file_path, container_id='file-viewer') %}
{{ file_path.split('/')[-1] }}
路径: {{ file_path }}
大小: 加载中...
修改时间: 加载中...
语言
字体大小
自动换行

正在加载文件内容...

{% endmacro %} ================================================ FILE: WebUI/templates/components/loading.html ================================================ {% macro spinner(size="md", color="primary", text="加载中...") %} {% set sizes = {"sm": "spinner-border-sm", "md": "", "lg": "spinner-border spinner-border-lg"} %}
{{ text }}
{% if text %} {{ text }} {% endif %}
{% endmacro %} {% macro progress(value=25, color="primary", striped=True, animated=True, label=True) %} {% set progress_classes = "progress-bar bg-" ~ color %} {% if striped %} {% set progress_classes = progress_classes ~ " progress-bar-striped" %} {% endif %} {% if animated %} {% set progress_classes = progress_classes ~ " progress-bar-animated" %} {% endif %}
{% if label %}{{ value }}%{% endif %}
{% endmacro %} {% macro full_page_loader(message="页面加载中...") %}
加载中...
{% if message %}

{{ message }}

{% endif %}
{% endmacro %} {% macro content_loader(size="md", container_class="") %}
{% if size == "sm" %}
{% elif size == "lg" %}
{% else %}
{% endif %}
{% endmacro %} ================================================ FILE: WebUI/templates/components/modals.html ================================================ {% macro confirm_modal(id="confirmModal", title="确认", message="您确定要执行此操作吗?", confirm_text="确认", cancel_text="取消", size="md") %} {% endmacro %} {% macro form_modal(id="formModal", title="表单", save_text="保存", cancel_text="取消", size="md") %} {% endmacro %} {% macro info_modal(id="infoModal", title="信息", ok_text="确定", size="md") %} {% endmacro %} {% macro ajax_modal(id="ajaxModal", title="加载内容", size="lg") %} {% endmacro %} ================================================ FILE: WebUI/templates/config/index.html ================================================ {% extends 'base.html' %} {% block title %}配置管理 - {{ app_name }}{% endblock %} {% block styles %} {% endblock %} {% block content %}

配置管理

加载中...

正在加载配置...

{% endblock %} {% block scripts %} {% endblock %} ================================================ FILE: WebUI/templates/explorer/index.html ================================================ {% extends "base.html" %} {% from "components/file_browser.html" import file_browser %} {% block title %}{{ page_title }}{% endblock %} {% block styles %} {% endblock %} {% block content %}

文件浏览器

{{ file_browser(container_id='file-explorer', initial_path=initial_path) }}
{% endblock %} {% block scripts %} {% endblock %} ================================================ FILE: WebUI/templates/explorer/view.html ================================================ {% extends "base.html" %} {% from "components/file_viewer.html" import file_viewer %} {% block title %}文件查看 - {{ file_path }}{% endblock %} {% block styles %} {{ super() }} {% endblock %} {% block content %}
文件查看器
返回
{{ file_viewer(file_path=file_path, container_id='file-viewer') }}
{% endblock %} {% block scripts %} {% endblock %} ================================================ FILE: WebUI/templates/logs/index.html ================================================ {% extends "base.html" %} {% from "components/file_browser.html" import file_browser %} {% block title %}{{ page_title }}{% endblock %} {% block styles %} {% endblock %} {% block content %}

日志管理

{{ file_browser(container_id='logs-browser', initial_path='logs') }}
{% endblock %} {% block scripts %} {% endblock %} ================================================ FILE: WebUI/templates/overview/index.html ================================================ {% extends "base.html" %} {% from "components/cards.html" import status_card, control_card, log_card, metric_card %} {% from "components/loading.html" import spinner %} {% block title %}{{ page_title }} - {{ app_name }}{% endblock %} {% block styles %} {{ super() }} {% endblock %} {% block content %}

概览

{{ status_card( title="收到消息数", value=metrics.messages, icon="fa-comment", color="primary", metric_type="messages" ) }}
{{ status_card( title="运行时长", value=metrics.uptime, icon="fa-clock", color="success", metric_type="uptime" ) }}
{{ status_card( title="数据库用户数", value=metrics.users, icon="fa-users", color="info", metric_type="users" ) }}
状态信息
运行状态 {% if bot_status.running %} 运行中 {% else %} 已停止 {% endif %}
进程ID {{ bot_status.pid or '无' }}
启动时间 {% if bot_status.start_time %} {{ bot_status.start_time|timestamp_to_datetime }} {% else %} 未启动 {% endif %}
实时日志
加载中...

正在加载日志...

{% endblock %} {% block scripts %} {% endblock %} ================================================ FILE: WebUI/templates/plugin/index.html ================================================ {% extends 'base.html' %} {% from 'components/file_browser.html' import file_browser %} {% block title %}插件管理 - {{ app_name }}{% endblock %} {% block styles %} {{ super() }} {% endblock %} {% block content %}

插件管理

加载中...
{% endblock %} {% block scripts %} {% endblock %} ================================================ FILE: WebUI/templates/tools/index.html ================================================ {% extends 'base.html' %} {% block title %}工具箱 - {{ app_name }}{% endblock %} {% block styles %} {{ super() }} {% endblock %} {% block content %}

工具箱

加载中...

正在加载工具...

{% endblock %} {% block scripts %} {{ super() }} {% endblock %} ================================================ FILE: WebUI/utils/async_to_sync.py ================================================ import asyncio import threading from functools import wraps _thread_local = threading.local() def async_to_sync(func): """将异步函数转换为同步函数的装饰器,支持多线程环境""" @wraps(func) def wrapper(*args, **kwargs): # 获取或创建当前线程的事件循环 try: loop = asyncio.get_event_loop() except RuntimeError: # 如果当前线程没有事件循环,创建一个新的 if not hasattr(_thread_local, 'loop'): _thread_local.loop = asyncio.new_event_loop() asyncio.set_event_loop(_thread_local.loop) loop = _thread_local.loop # 运行协程并返回结果 coroutine = func(*args, **kwargs) if loop.is_running(): # 如果循环已经运行,创建任务 return asyncio.create_task(coroutine) else: # 如果循环未运行,运行直到完成 return loop.run_until_complete(coroutine) return wrapper ================================================ FILE: WebUI/utils/auth_utils.py ================================================ import functools from flask import session, redirect, url_for, request, flash from WebUI.config import ADMIN_USERNAME, ADMIN_PASSWORD def login_required(view): """ 用于装饰需要登录才能访问的视图函数 参数: view: 视图函数 返回: 装饰后的视图函数 """ @functools.wraps(view) def wrapped_view(**kwargs): # 检查会话中是否有 authenticated 标志 if not session.get('authenticated', False): # 保存请求的URL用于登录后重定向 session['redirect_url'] = request.url flash('请先登录后访问此页面', 'warning') return redirect(url_for('auth.login')) return view(**kwargs) return wrapped_view def verify_credentials(username, password): """ 验证用户名和密码 参数: username: 用户名 password: 密码 返回: bool: 验证是否成功 """ return username == ADMIN_USERNAME and password == ADMIN_PASSWORD ================================================ FILE: WebUI/utils/singleton.py ================================================ class Singleton(type): _instances = {} def __call__(cls, *args, **kwargs): if cls not in cls._instances: cls._instances[cls] = super().__call__(*args, **kwargs) return cls._instances[cls] @classmethod def reset_instance(mcs, cls): """重置指定类的单例实例""" if cls in mcs._instances: del mcs._instances[cls] @classmethod def reset_all(mcs): """重置所有单例实例""" mcs._instances.clear() ================================================ FILE: WebUI/utils/template_filters.py ================================================ from datetime import datetime def timestamp_to_datetime(timestamp): """ 将时间戳转换为格式化日期时间字符串 参数: timestamp (float): UNIX时间戳 返回: str: 格式化的日期时间字符串 """ if not timestamp: return "未知" try: dt = datetime.fromtimestamp(float(timestamp)) return dt.strftime('%Y-%m-%d %H:%M:%S') except (ValueError, TypeError): return "无效时间戳" def format_file_size(size_bytes): """ 格式化文件大小 参数: size_bytes (int): 文件大小(字节) 返回: str: 格式化的文件大小字符串 """ if size_bytes < 1024: return f"{size_bytes} B" elif size_bytes < 1024 * 1024: return f"{size_bytes / 1024:.1f} KB" elif size_bytes < 1024 * 1024 * 1024: return f"{size_bytes / (1024 * 1024):.1f} MB" else: return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" def register_template_filters(app): """ 注册所有模板过滤器到Flask应用 参数: app: Flask应用实例 """ app.jinja_env.filters['timestamp_to_datetime'] = timestamp_to_datetime app.jinja_env.filters['format_file_size'] = format_file_size ================================================ FILE: WechatAPI/Client/__init__.py ================================================ from WechatAPI.errors import * from .base import WechatAPIClientBase, Proxy, Section from .chatroom import ChatroomMixin from .friend import FriendMixin from .hongbao import HongBaoMixin from .login import LoginMixin from .message import MessageMixin from .protect import protector from .protect import protector from .tool import ToolMixin from .user import UserMixin class WechatAPIClient(LoginMixin, MessageMixin, FriendMixin, ChatroomMixin, UserMixin, ToolMixin, HongBaoMixin): # 这里都是需要结合多个功能的方法 async def send_at_message(self, wxid: str, content: str, at: list[str]) -> tuple[int, int, int]: """发送@消息 Args: wxid (str): 接收人 content (str): 消息内容 at (list[str]): 要@的用户ID列表 Returns: tuple[int, int, int]: 包含以下三个值的元组: - ClientMsgid (int): 客户端消息ID - CreateTime (int): 创建时间 - NewMsgId (int): 新消息ID Raises: UserLoggedOut: 用户未登录时抛出 BanProtection: 新设备登录4小时内操作时抛出 """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") output = "" for id in at: nickname = await self.get_nickname(id) output += f"@{nickname}\u2005" output += content return await self.send_text_message(wxid, output, at) ================================================ FILE: WechatAPI/Client/base.py ================================================ from dataclasses import dataclass from WechatAPI.errors import * @dataclass class Proxy: """代理(无效果,别用!) Args: ip (str): 代理服务器IP地址 port (int): 代理服务器端口 username (str, optional): 代理认证用户名. 默认为空字符串 password (str, optional): 代理认证密码. 默认为空字符串 """ ip: str port: int username: str = "" password: str = "" @dataclass class Section: """数据段配置类 Args: data_len (int): 数据长度 start_pos (int): 起始位置 """ data_len: int start_pos: int class WechatAPIClientBase: """微信API客户端基类 Args: ip (str): 服务器IP地址 port (int): 服务器端口 Attributes: wxid (str): 微信ID nickname (str): 昵称 alias (str): 别名 phone (str): 手机号 ignore_protect (bool): 是否忽略保护机制 """ def __init__(self, ip: str, port: int): self.ip = ip self.port = port self.wxid = "" self.nickname = "" self.alias = "" self.phone = "" self.ignore_protect = False # 调用所有 Mixin 的初始化方法 super().__init__() @staticmethod def error_handler(json_resp): """处理API响应中的错误码 Args: json_resp (dict): API响应的JSON数据 Raises: ValueError: 参数错误时抛出 MarshallingError: 序列化错误时抛出 UnmarshallingError: 反序列化错误时抛出 MMTLSError: MMTLS初始化错误时抛出 PacketError: 数据包长度错误时抛出 UserLoggedOut: 用户已退出登录时抛出 ParsePacketError: 解析数据包错误时抛出 DatabaseError: 数据库错误时抛出 Exception: 其他类型错误时抛出 """ code = json_resp.get("Code") if code == -1: # 参数错误 raise ValueError(json_resp.get("Message")) elif code == -2: # 其他错误 raise Exception(json_resp.get("Message")) elif code == -3: # 序列化错误 raise MarshallingError(json_resp.get("Message")) elif code == -4: # 反序列化错误 raise UnmarshallingError(json_resp.get("Message")) elif code == -5: # MMTLS初始化错误 raise MMTLSError(json_resp.get("Message")) elif code == -6: # 收到的数据包长度错误 raise PacketError(json_resp.get("Message")) elif code == -7: # 已退出登录 raise UserLoggedOut("Already logged out") elif code == -8: # 链接过期 raise Exception(json_resp.get("Message")) elif code == -9: # 解析数据包错误 raise ParsePacketError(json_resp.get("Message")) elif code == -10: # 数据库错误 raise DatabaseError(json_resp.get("Message")) elif code == -11: # 登陆异常 raise UserLoggedOut(json_resp.get("Message")) elif code == -12: # 操作过于频繁 raise Exception(json_resp.get("Message")) elif code == -13: # 上传失败 raise Exception(json_resp.get("Message")) ================================================ FILE: WechatAPI/Client/chatroom.py ================================================ from typing import Union, Any import aiohttp from .base import * from .protect import protector from ..errors import * class ChatroomMixin(WechatAPIClientBase): async def add_chatroom_member(self, chatroom: str, wxid: str) -> bool: """添加群成员(群聊最多40人) Args: chatroom: 群聊wxid wxid: 要添加的wxid Returns: bool: 成功返回True, 失败False或者报错 """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Chatroom": chatroom, "InviteWxids": wxid} response = await session.post(f'http://{self.ip}:{self.port}/AddChatroomMember', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp) async def get_chatroom_announce(self, chatroom: str) -> dict: """获取群聊公告 Args: chatroom: 群聊id Returns: dict: 群聊信息字典 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Chatroom": chatroom} response = await session.post(f'http://{self.ip}:{self.port}/GetChatroomInfo', json=json_param) json_resp = await response.json() if json_resp.get("Success"): data = dict(json_resp.get("Data")) data.pop("BaseResponse") return data else: self.error_handler(json_resp) async def get_chatroom_info(self, chatroom: str) -> dict: """获取群聊信息 Args: chatroom: 群聊id Returns: dict: 群聊信息字典 """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Chatroom": chatroom} response = await session.post(f'http://{self.ip}:{self.port}/GetChatroomInfoNoAnnounce', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("ContactList")[0] else: self.error_handler(json_resp) async def get_chatroom_member_list(self, chatroom: str) -> list[dict]: """获取群聊成员列表 Args: chatroom: 群聊id Returns: list[dict]: 群聊成员列表 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Chatroom": chatroom} response = await session.post(f'http://{self.ip}:{self.port}/GetChatroomMemberDetail', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("NewChatroomData").get("ChatRoomMember") else: self.error_handler(json_resp) async def get_chatroom_qrcode(self, chatroom: str) -> dict[str, Any]: """获取群聊二维码 Args: chatroom: 群聊id Returns: dict: {"base64": 二维码的base64, "description": 二维码描述} """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(86400): raise BanProtection("获取二维码需要在登录后24小时才可使用") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Chatroom": chatroom} response = await session.post(f'http://{self.ip}:{self.port}/GetChatroomQRCode', json=json_param) json_resp = await response.json() if json_resp.get("Success"): data = json_resp.get("Data") return {"base64": data.get("qrcode").get("buffer"), "description": data.get("revokeQrcodeWording")} else: self.error_handler(json_resp) async def invite_chatroom_member(self, wxid: Union[str, list], chatroom: str) -> bool: """邀请群聊成员(群聊大于40人) Args: wxid: 要邀请的用户wxid或wxid列表 chatroom: 群聊id Returns: bool: 成功返回True, 失败False或者报错 """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") if isinstance(wxid, list): wxid = ",".join(wxid) async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Chatroom": chatroom, "InviteWxids": wxid} response = await session.post(f'http://{self.ip}:{self.port}/InviteChatroomMember', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp) ================================================ FILE: WechatAPI/Client/friend.py ================================================ from typing import Union import aiohttp from .base import * from .protect import protector from ..errors import * class FriendMixin(WechatAPIClientBase): async def accept_friend(self, scene: int, v1: str, v2: str) -> bool: """接受好友请求 主动添加好友单天上限如下所示:1小时内上限为 5个,超过上限时,无法发出好友请求,也收不到好友请求。 - 新账号:5/天 - 注册超过7天:10个/天 - 注册满3个月&&近期登录过该电脑:15/天 - 注册满6个月&&近期经常登录过该电脑:20/天 - 注册满6个月&&近期频繁登陆过该电脑:30/天 - 注册1年以上&&一直登录:50/天 - 上一次通过好友到下一次通过间隔20-40s - 收到加人申请,到通过好友申请(每天最多通过300个好友申请),间隔30s+(随机时间) Args: scene: 来源 在消息的xml获取 v1: v1key v2: v2key Returns: bool: 操作是否成功 """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Scene": scene, "V1": v1, "V2": v2} response = await session.post(f'http://{self.ip}:{self.port}/AcceptFriend', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp) async def get_contact(self, wxid: Union[str, list[str]]) -> Union[dict, list[dict]]: """获取联系人信息 Args: wxid: 联系人wxid, 可以是多个wxid在list里,也可查询chatroom Returns: Union[dict, list[dict]]: 单个联系人返回dict,多个联系人返回list[dict] """ if not self.wxid: raise UserLoggedOut("请先登录") if isinstance(wxid, list): wxid = ",".join(wxid) async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "RequestWxids": wxid} response = await session.post(f'http://{self.ip}:{self.port}/GetContact', json=json_param) json_resp = await response.json() if json_resp.get("Success"): contact_list = json_resp.get("Data").get("ContactList") if len(contact_list) == 1: return contact_list[0] else: return contact_list else: self.error_handler(json_resp) async def get_contract_detail(self, wxid: Union[str, list[str]], chatroom: str = "") -> list: """获取联系人详情 Args: wxid: 联系人wxid chatroom: 群聊wxid Returns: list: 联系人详情列表 """ if not self.wxid: raise UserLoggedOut("请先登录") if isinstance(wxid, list): if len(wxid) > 20: raise ValueError("一次最多查询20个联系人") wxid = ",".join(wxid) async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "RequestWxids": wxid, "Chatroom": chatroom} response = await session.post(f'http://{self.ip}:{self.port}/GetContractDetail', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("ContactList") else: self.error_handler(json_resp) async def get_contract_list(self, wx_seq: int = 0, chatroom_seq: int = 0) -> dict: """获取联系人列表 Args: wx_seq: 联系人序列 chatroom_seq: 群聊序列 Returns: dict: 联系人列表数据 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "CurrentWxcontactSeq": wx_seq, "CurrentChatroomContactSeq": chatroom_seq} response = await session.post(f'http://{self.ip}:{self.port}/GetContractList', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data") else: self.error_handler(json_resp) async def get_nickname(self, wxid: Union[str, list[str]]) -> Union[str, list[str]]: """获取用户昵称 Args: wxid: 用户wxid,可以是单个wxid或最多20个wxid的列表 Returns: Union[str, list[str]]: 如果输入单个wxid返回str,如果输入wxid列表则返回对应的昵称列表 """ data = await self.get_contract_detail(wxid) if isinstance(wxid, str): try: return data[0].get("NickName").get("string") except: return "" else: result = [] for contact in data: try: result.append(contact.get("NickName").get("string")) except: result.append("") return result ================================================ FILE: WechatAPI/Client/hongbao.py ================================================ import aiohttp from .base import * from ..errors import * class HongBaoMixin(WechatAPIClientBase): async def get_hongbao_detail(self, xml: str, encrypt_key: str, encrypt_userinfo: str) -> dict: """获取红包详情 Args: xml: 红包 XML 数据 encrypt_key: 加密密钥 encrypt_userinfo: 加密的用户信息 Returns: dict: 红包详情数据 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Xml": xml, "EncryptKey": encrypt_key, "EncryptUserinfo": encrypt_userinfo} response = await session.post(f'http://{self.ip}:{self.port}/GetHongBaoDetail', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data") else: self.error_handler(json_resp) ================================================ FILE: WechatAPI/Client/login.py ================================================ import hashlib import io import string from random import choice from typing import Union import aiohttp import qrcode from .base import * from .protect import protector from ..errors import * class LoginMixin(WechatAPIClientBase): async def is_running(self) -> bool: """检查WechatAPI是否在运行。 Returns: bool: 如果WechatAPI正在运行返回True,否则返回False。 """ try: async with aiohttp.ClientSession() as session: response = await session.get(f'http://{self.ip}:{self.port}/IsRunning') return await response.text() == 'OK' except aiohttp.client_exceptions.ClientConnectorError: return False async def get_qr_code(self, device_name: str, device_id: str = "", proxy: Proxy = None) -> ( str, str): """获取登录二维码。 Args: device_name (str): 设备名称 device_id (str, optional): 设备ID. Defaults to "". proxy (Proxy, optional): 代理信息. Defaults to None. Returns: tuple[str, str, str]: 返回UUID,URL,登录二维码 Raises: 根据error_handler处理错误 """ async with aiohttp.ClientSession() as session: json_param = {'DeviceName': device_name, 'DeviceID': device_id} if proxy: json_param['ProxyInfo'] = {'ProxyIp': f'{proxy.ip}:{proxy.port}', 'ProxyPassword': proxy.password, 'ProxyUser': proxy.username} response = await session.post(f'http://{self.ip}:{self.port}/GetQRCode', json=json_param) json_resp = await response.json() if json_resp.get("Success"): qr = qrcode.QRCode( version=1, box_size=10, border=4, ) qr.add_data(f'http://weixin.qq.com/x/{json_resp.get("Data").get("Uuid")}') qr.make(fit=True) f = io.StringIO() qr.print_ascii(out=f) f.seek(0) return json_resp.get("Data").get("Uuid"), json_resp.get("Data").get("QRCodeURL"), f.read() else: self.error_handler(json_resp) async def check_login_uuid(self, uuid: str, device_id: str = "") -> tuple[bool, Union[dict, int]]: """检查登录的UUID状态。 Args: uuid (str): 登录的UUID device_id (str, optional): 设备ID. Defaults to "". Returns: tuple[bool, Union[dict, int]]: 如果登录成功返回(True, 用户信息),否则返回(False, 过期时间) Raises: 根据error_handler处理错误 """ async with aiohttp.ClientSession() as session: json_param = {"Uuid": uuid} response = await session.post(f'http://{self.ip}:{self.port}/CheckUuid', json=json_param) json_resp = await response.json() if json_resp.get("Success"): if json_resp.get("Data").get("acctSectResp", ""): self.wxid = json_resp.get("Data").get("acctSectResp").get("userName") self.nickname = json_resp.get("Data").get("acctSectResp").get("nickName") protector.update_login_status(device_id=device_id) return True, json_resp.get("Data") else: return False, json_resp.get("Data").get("expiredTime") else: self.error_handler(json_resp) async def log_out(self) -> bool: """登出当前账号。 Returns: bool: 登出成功返回True,否则返回False Raises: UserLoggedOut: 如果未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid} response = await session.post(f'http://{self.ip}:{self.port}/Logout', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True elif json_resp.get("Success"): return False else: self.error_handler(json_resp) async def awaken_login(self, wxid: str = "") -> str: """唤醒登录。 Args: wxid (str, optional): 要唤醒的微信ID. Defaults to "". Returns: str: 返回新的登录UUID Raises: Exception: 如果未提供wxid且未登录 LoginError: 如果无法获取UUID 根据error_handler处理错误 """ if not wxid and not self.wxid: raise Exception("Please login using QRCode first") if not wxid and self.wxid: wxid = self.wxid async with aiohttp.ClientSession() as session: json_param = {"Wxid": wxid} response = await session.post(f'http://{self.ip}:{self.port}/AwakenLogin', json=json_param) json_resp = await response.json() if json_resp.get("Success") and json_resp.get("Data").get("QrCodeResponse").get("Uuid"): return json_resp.get("Data").get("QrCodeResponse").get("Uuid") elif not json_resp.get("Data").get("QrCodeResponse").get("Uuid"): raise LoginError("Please login using QRCode first") else: self.error_handler(json_resp) async def get_cached_info(self, wxid: str = None) -> dict: """获取登录缓存信息。 Args: wxid (str, optional): 要查询的微信ID. Defaults to None. Returns: dict: 返回缓存信息,如果未提供wxid且未登录返回空字典 """ if not wxid: wxid = self.wxid if not wxid: return {} async with aiohttp.ClientSession() as session: json_param = {"Wxid": wxid} response = await session.post(f'http://{self.ip}:{self.port}/GetCachedInfo', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data") else: return {} async def heartbeat(self) -> bool: """发送心跳包。 Returns: bool: 成功返回True,否则返回False Raises: UserLoggedOut: 如果未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid} response = await session.post(f'http://{self.ip}:{self.port}/Heartbeat', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp) async def start_auto_heartbeat(self) -> bool: """开始自动心跳。 Returns: bool: 成功返回True,否则返回False Raises: UserLoggedOut: 如果未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid} response = await session.post(f'http://{self.ip}:{self.port}/AutoHeartbeatStart', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp) async def stop_auto_heartbeat(self) -> bool: """停止自动心跳。 Returns: bool: 成功返回True,否则返回False Raises: UserLoggedOut: 如果未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid} response = await session.post(f'http://{self.ip}:{self.port}/AutoHeartbeatStop', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp) async def get_auto_heartbeat_status(self) -> bool: """获取自动心跳状态。 Returns: bool: 如果正在运行返回True,否则返回False Raises: UserLoggedOut: 如果未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid} response = await session.post(f'http://{self.ip}:{self.port}/AutoHeartbeatStatus', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("Running") else: return self.error_handler(json_resp) @staticmethod def create_device_name() -> str: """生成一个随机的设备名。 Returns: str: 返回生成的设备名 """ first_names = [ "Oliver", "Emma", "Liam", "Ava", "Noah", "Sophia", "Elijah", "Isabella", "James", "Mia", "William", "Amelia", "Benjamin", "Harper", "Lucas", "Evelyn", "Henry", "Abigail", "Alexander", "Ella", "Jackson", "Scarlett", "Sebastian", "Grace", "Aiden", "Chloe", "Matthew", "Zoey", "Samuel", "Lily", "David", "Aria", "Joseph", "Riley", "Carter", "Nora", "Owen", "Luna", "Daniel", "Sofia", "Gabriel", "Ellie", "Matthew", "Avery", "Isaac", "Mila", "Leo", "Julian", "Layla" ] last_names = [ "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin", "Lee", "Perez", "Thompson", "White", "Harris", "Sanchez", "Clark", "Ramirez", "Lewis", "Robinson", "Walker", "Young", "Allen", "King", "Wright", "Scott", "Torres", "Nguyen", "Hill", "Flores", "Green", "Adams", "Nelson", "Baker", "Hall", "Rivera", "Campbell", "Mitchell", "Carter", "Roberts", "Gomez", "Phillips", "Evans" ] return choice(first_names) + " " + choice(last_names) + "'s Pad" @staticmethod def create_device_id(s: str = "") -> str: """生成设备ID。 Args: s (str, optional): 用于生成ID的字符串. Defaults to "". Returns: str: 返回生成的设备ID """ if s == "" or s == "string": s = ''.join(choice(string.ascii_letters) for _ in range(15)) md5_hash = hashlib.md5(s.encode()).hexdigest() return "49" + md5_hash[2:] ================================================ FILE: WechatAPI/Client/message.py ================================================ import asyncio import base64 import os from asyncio import Future from asyncio import Queue, sleep from io import BytesIO from pathlib import Path from typing import Union import aiohttp import pysilk from loguru import logger from pydub import AudioSegment from pymediainfo import MediaInfo from .base import * from .protect import protector from ..errors import * class MessageMixin(WechatAPIClientBase): def __init__(self, ip: str, port: int): # 初始化消息队列 super().__init__(ip, port) self._message_queue = Queue() self._is_processing = False async def _process_message_queue(self): """ 处理消息队列的异步方法 """ if self._is_processing: return self._is_processing = True while True: if self._message_queue.empty(): self._is_processing = False break func, args, kwargs, future = await self._message_queue.get() try: result = await func(*args, **kwargs) future.set_result(result) except Exception as e: future.set_exception(e) finally: self._message_queue.task_done() await sleep(1) # 消息发送间隔1秒 async def _queue_message(self, func, *args, **kwargs): """ 将消息添加到队列 """ future = Future() await self._message_queue.put((func, args, kwargs, future)) if not self._is_processing: asyncio.create_task(self._process_message_queue()) return await future async def revoke_message(self, wxid: str, client_msg_id: int, create_time: int, new_msg_id: int) -> bool: """撤回消息。 Args: wxid (str): 接收人wxid client_msg_id (int): 发送消息的返回值 create_time (int): 发送消息的返回值 new_msg_id (int): 发送消息的返回值 Returns: bool: 成功返回True,失败返回False Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "ClientMsgId": client_msg_id, "CreateTime": create_time, "NewMsgId": new_msg_id} response = await session.post(f'http://{self.ip}:{self.port}/RevokeMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("消息撤回成功: 对方wxid:{} ClientMsgId:{} CreateTime:{} NewMsgId:{}", wxid, client_msg_id, new_msg_id) return True else: self.error_handler(json_resp) async def send_text_message(self, wxid: str, content: str, at: Union[list, str] = "") -> tuple[int, int, int]: """发送文本消息。 Args: wxid (str): 接收人wxid content (str): 消息内容 at (list, str, optional): 要@的用户 Returns: tuple[int, int, int]: 返回(ClientMsgid, CreateTime, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ return await self._queue_message(self._send_text_message, wxid, content, at) async def _send_text_message(self, wxid: str, content: str, at: list[str] = None) -> tuple[int, int, int]: """ 实际发送文本消息的方法 """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") if isinstance(at, str): at_str = at elif isinstance(at, list): if at is None: at = [] at_str = ",".join(at) else: raise ValueError("Argument 'at' should be str or list") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Content": content, "Type": 1, "At": at_str} response = await session.post(f'http://{self.ip}:{self.port}/SendTextMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("发送文字消息: 对方wxid:{} at:{} 内容:{}", wxid, at, content) data = json_resp.get("Data") return data.get("List")[0].get("ClientMsgid"), data.get("List")[0].get("Createtime"), data.get("List")[ 0].get("NewMsgId") else: self.error_handler(json_resp) async def send_image_message(self, wxid: str, image: Union[str, bytes, os.PathLike]) -> tuple[int, int, int]: """发送图片消息。 Args: wxid (str): 接收人wxid image (str, byte, os.PathLike): 图片,支持base64字符串,图片byte,图片路径 Returns: tuple[int, int, int]: 返回(ClientImgId, CreateTime, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 ValueError: image_path和image_base64都为空或都不为空时 根据error_handler处理错误 """ return await self._queue_message(self._send_image_message, wxid, image) async def _send_image_message(self, wxid: str, image: Union[str, bytes, os.PathLike]) -> tuple[ int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") if isinstance(image, str): pass elif isinstance(image, bytes): image = base64.b64encode(image).decode() elif isinstance(image, os.PathLike): with open(image, 'rb') as f: image = base64.b64encode(f.read()).decode() else: raise ValueError("Argument 'image' can only be str, bytes, or os.PathLike") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Base64": image} response = await session.post(f'http://{self.ip}:{self.port}/SendImageMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): json_param.pop('Base64') logger.info("发送图片消息: 对方wxid:{} 图片base64略", wxid) data = json_resp.get("Data") return data.get("ClientImgId").get("string"), data.get("CreateTime"), data.get("Newmsgid") else: self.error_handler(json_resp) async def send_video_message(self, wxid: str, video: Union[str, bytes, os.PathLike], image: [str, bytes, os.PathLike] = None): """发送视频消息。不推荐使用,上传速度很慢300KB/s。如要使用,可压缩视频,或者发送链接卡片而不是视频。 Args: wxid (str): 接收人wxid video (str, bytes, os.PathLike): 视频 接受base64字符串,字节,文件路径 image (str, bytes, os.PathLike): 视频封面图片 接受base64字符串,字节,文件路径 Returns: tuple[int, int]: 返回(ClientMsgid, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 ValueError: 视频或图片参数都为空或都不为空时 根据error_handler处理错误 """ if not image: image = Path(os.path.join(Path(__file__).resolve().parent, "fallback.png")) # get video base64 and duration if isinstance(video, str): vid_base64 = video video = base64.b64decode(video) file_len = len(video) media_info = MediaInfo.parse(BytesIO(video)) elif isinstance(video, bytes): vid_base64 = base64.b64encode(video).decode() file_len = len(video) media_info = MediaInfo.parse(BytesIO(video)) elif isinstance(video, os.PathLike): with open(video, "rb") as f: file_len = len(f.read()) vid_base64 = base64.b64encode(f.read()).decode() media_info = MediaInfo.parse(video) else: raise ValueError("video should be str, bytes, or path") duration = media_info.tracks[0].duration # get image base64 if isinstance(image, str): image_base64 = image elif isinstance(image, bytes): image_base64 = base64.b64encode(image).decode() elif isinstance(image, os.PathLike): with open(image, "rb") as f: image_base64 = base64.b64encode(f.read()).decode() else: raise ValueError("image should be str, bytes, or path") # 打印预估时间,300KB/s predict_time = int(file_len / 1024 / 300) logger.info("开始发送视频: 对方wxid:{} 视频base64略 图片base64略 预计耗时:{}秒", wxid, predict_time) async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Base64": vid_base64, "ImageBase64": image_base64, "PlayLength": duration} async with session.post(f'http://{self.ip}:{self.port}/SendVideoMsg', json=json_param) as resp: json_resp = await resp.json() if json_resp.get("Success"): json_param.pop('Base64') json_param.pop('ImageBase64') logger.info("发送视频成功: 对方wxid:{} 时长:{} 视频base64略 图片base64略", wxid, duration) data = json_resp.get("Data") return data.get("clientMsgId"), data.get("newMsgId") else: self.error_handler(json_resp) async def send_voice_message(self, wxid: str, voice: Union[str, bytes, os.PathLike], format: str = "amr") -> \ tuple[int, int, int]: """发送语音消息。 Args: wxid (str): 接收人wxid voice (str, bytes, os.PathLike): 语音 接受base64字符串,字节,文件路径 format (str, optional): 语音格式,支持amr/wav/mp3. Defaults to "amr". Returns: tuple[int, int, int]: 返回(ClientMsgid, CreateTime, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 ValueError: voice_path和voice_base64都为空或都不为空时,或format不支持时 根据error_handler处理错误 """ return await self._queue_message(self._send_voice_message, wxid, voice, format) async def _send_voice_message(self, wxid: str, voice: Union[str, bytes, os.PathLike], format: str = "amr") -> \ tuple[int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") elif format not in ["amr", "wav", "mp3"]: raise ValueError("format must be one of amr, wav, mp3") # read voice to byte if isinstance(voice, str): voice_byte = base64.b64decode(voice) elif isinstance(voice, bytes): voice_byte = voice elif isinstance(voice, os.PathLike): with open(voice, "rb") as f: voice_byte = f.read() else: raise ValueError("voice should be str, bytes, or path") # get voice duration and b64 if format.lower() == "amr": audio = AudioSegment.from_file(BytesIO(voice_byte), format="amr") voice_base64 = base64.b64encode(voice_byte).decode() elif format.lower() == "wav": audio = AudioSegment.from_file(BytesIO(voice_byte), format="wav").set_channels(1) audio = audio.set_frame_rate(self._get_closest_frame_rate(audio.frame_rate)) voice_base64 = base64.b64encode( await pysilk.async_encode(audio.raw_data, sample_rate=audio.frame_rate)).decode() elif format.lower() == "mp3": audio = AudioSegment.from_file(BytesIO(voice_byte), format="mp3").set_channels(1) audio = audio.set_frame_rate(self._get_closest_frame_rate(audio.frame_rate)) voice_base64 = base64.b64encode( await pysilk.async_encode(audio.raw_data, sample_rate=audio.frame_rate)).decode() else: raise ValueError("format must be one of amr, wav, mp3") duration = len(audio) format_dict = {"amr": 0, "wav": 4, "mp3": 4} async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Base64": voice_base64, "VoiceTime": duration, "Type": format_dict[format]} response = await session.post(f'http://{self.ip}:{self.port}/SendVoiceMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): json_param.pop('Base64') logger.info("发送语音消息: 对方wxid:{} 时长:{} 格式:{} 音频base64略", wxid, duration, format) data = json_resp.get("Data") return int(data.get("ClientMsgId")), data.get("CreateTime"), data.get("NewMsgId") else: self.error_handler(json_resp) @staticmethod def _get_closest_frame_rate(frame_rate: int) -> int: supported = [8000, 12000, 16000, 24000] closest_rate = None smallest_diff = float('inf') for num in supported: diff = abs(frame_rate - num) if diff < smallest_diff: smallest_diff = diff closest_rate = num return closest_rate async def send_link_message(self, wxid: str, url: str, title: str = "", description: str = "", thumb_url: str = "") -> tuple[str, int, int]: """发送链接消息。 Args: wxid (str): 接收人wxid url (str): 跳转链接 title (str, optional): 标题. Defaults to "". description (str, optional): 描述. Defaults to "". thumb_url (str, optional): 缩略图链接. Defaults to "". Returns: tuple[str, int, int]: 返回(ClientMsgid, CreateTime, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ return await self._queue_message(self._send_link_message, wxid, url, title, description, thumb_url) async def _send_link_message(self, wxid: str, url: str, title: str = "", description: str = "", thumb_url: str = "") -> tuple[int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Url": url, "Title": title, "Desc": description, "ThumbUrl": thumb_url} response = await session.post(f'http://{self.ip}:{self.port}/SendShareLink', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("发送链接消息: 对方wxid:{} 链接:{} 标题:{} 描述:{} 缩略图链接:{}", wxid, url, title, description, thumb_url) data = json_resp.get("Data") return data.get("clientMsgId"), data.get("createTime"), data.get("newMsgId") else: self.error_handler(json_resp) async def send_emoji_message(self, wxid: str, md5: str, total_length: int) -> list[dict]: """发送表情消息。 Args: wxid (str): 接收人wxid md5 (str): 表情md5值 total_length (int): 表情总长度 Returns: list[dict]: 返回表情项列表(list of emojiItem) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ return await self._queue_message(self._send_emoji_message, wxid, md5, total_length) async def _send_emoji_message(self, wxid: str, md5: str, total_length: int) -> tuple[int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Md5": md5, "TotalLen": total_length} response = await session.post(f'http://{self.ip}:{self.port}/SendEmojiMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("发送表情消息: 对方wxid:{} md5:{} 总长度:{}", wxid, md5, total_length) return json_resp.get("Data").get("emojiItem") else: self.error_handler(json_resp) async def send_card_message(self, wxid: str, card_wxid: str, card_nickname: str, card_alias: str = "") -> tuple[ int, int, int]: """发送名片消息。 Args: wxid (str): 接收人wxid card_wxid (str): 名片用户的wxid card_nickname (str): 名片用户的昵称 card_alias (str, optional): 名片用户的备注. Defaults to "". Returns: tuple[int, int, int]: 返回(ClientMsgid, CreateTime, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ return await self._queue_message(self._send_card_message, wxid, card_wxid, card_nickname, card_alias) async def _send_card_message(self, wxid: str, card_wxid: str, card_nickname: str, card_alias: str = "") -> tuple[ int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "CardWxid": card_wxid, "CardAlias": card_alias, "CardNickname": card_nickname} response = await session.post(f'http://{self.ip}:{self.port}/SendCardMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("发送名片消息: 对方wxid:{} 名片wxid:{} 名片备注:{} 名片昵称:{}", wxid, card_wxid, card_alias, card_nickname) data = json_resp.get("Data") return data.get("List")[0].get("ClientMsgid"), data.get("List")[0].get("Createtime"), data.get("List")[ 0].get("NewMsgId") else: self.error_handler(json_resp) async def send_app_message(self, wxid: str, xml: str, type: int) -> tuple[str, int, int]: """发送应用消息。 Args: wxid (str): 接收人wxid xml (str): 应用消息的xml内容 type (int): 应用消息类型 Returns: tuple[str, int, int]: 返回(ClientMsgid, CreateTime, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ return await self._queue_message(self._send_app_message, wxid, xml, type) async def _send_app_message(self, wxid: str, xml: str, type: int) -> tuple[int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Xml": xml, "Type": type} response = await session.post(f'http://{self.ip}:{self.port}/SendAppMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): json_param["Xml"] = json_param["Xml"].replace("\n", "") logger.info("发送app消息: 对方wxid:{} 类型:{} xml:{}", wxid, type, json_param["Xml"]) return json_resp.get("Data").get("clientMsgId"), json_resp.get("Data").get( "createTime"), json_resp.get("Data").get("newMsgId") else: self.error_handler(json_resp) async def send_cdn_file_msg(self, wxid: str, xml: str) -> tuple[str, int, int]: """转发文件消息。 Args: wxid (str): 接收人wxid xml (str): 要转发的文件消息xml内容 Returns: tuple[str, int, int]: 返回(ClientMsgid, CreateTime, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ return await self._queue_message(self._send_cdn_file_msg, wxid, xml) async def _send_cdn_file_msg(self, wxid: str, xml: str) -> tuple[int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Content": xml} response = await session.post(f'http://{self.ip}:{self.port}/SendCDNFileMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("转发文件消息: 对方wxid:{} xml:{}", wxid, xml) data = json_resp.get("Data") return data.get("clientMsgId"), data.get("createTime"), data.get("newMsgId") else: self.error_handler(json_resp) async def send_cdn_img_msg(self, wxid: str, xml: str) -> tuple[str, int, int]: """转发图片消息。 Args: wxid (str): 接收人wxid xml (str): 要转发的图片消息xml内容 Returns: tuple[str, int, int]: 返回(ClientImgId, CreateTime, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ return await self._queue_message(self._send_cdn_img_msg, wxid, xml) async def _send_cdn_img_msg(self, wxid: str, xml: str) -> tuple[int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Content": xml} response = await session.post(f'http://{self.ip}:{self.port}/SendCDNImgMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("转发图片消息: 对方wxid:{} xml:{}", wxid, xml) data = json_resp.get("Data") return data.get("ClientImgId").get("string"), data.get("CreateTime"), data.get("Newmsgid") else: self.error_handler(json_resp) async def send_cdn_video_msg(self, wxid: str, xml: str) -> tuple[str, int]: """转发视频消息。 Args: wxid (str): 接收人wxid xml (str): 要转发的视频消息xml内容 Returns: tuple[str, int]: 返回(ClientMsgid, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ return await self._queue_message(self._send_cdn_video_msg, wxid, xml) async def _send_cdn_video_msg(self, wxid: str, xml: str) -> tuple[int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Content": xml} response = await session.post(f'http://{self.ip}:{self.port}/SendCDNVideoMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("转发视频消息: 对方wxid:{} xml:{}", wxid, xml) data = json_resp.get("Data") return data.get("clientMsgId"), data.get("newMsgId") else: self.error_handler(json_resp) async def sync_message(self) -> dict: """同步消息。 Returns: dict: 返回同步到的消息数据 Raises: UserLoggedOut: 未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: json_param = {"Wxid": self.wxid, "Scene": 0, "Synckey": ""} response = await session.post(f'http://{self.ip}:{self.port}/Sync', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data") else: self.error_handler(json_resp) ================================================ FILE: WechatAPI/Client/protect.py ================================================ import json import os from datetime import datetime class Singleton(type): """单例模式的元类。 用于确保一个类只有一个实例。 Attributes: _instances (dict): 存储类的实例的字典 """ _instances = {} def __call__(cls, *args, **kwargs): """创建或返回类的单例实例。 Args: *args: 位置参数 **kwargs: 关键字参数 Returns: object: 类的单例实例 """ if cls not in cls._instances: cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) return cls._instances[cls] class Protect(metaclass=Singleton): """保护类,风控保护机制。 使用单例模式确保全局只有一个实例。 Attributes: login_stat_path (str): 登录状态文件的路径 login_stat (dict): 登录状态信息 login_time (int): 最后登录时间戳 login_device_id (str): 最后登录的设备ID """ def __init__(self): """初始化保护类实例。 创建或加载登录状态文件,初始化登录时间和设备ID。 """ self.login_stat_path = os.path.join(os.path.dirname(__file__), "login_stat.json") if not os.path.exists(self.login_stat_path): default_config = { "login_time": 0, "device_id": "" } with open(self.login_stat_path, "w", encoding="utf-8") as f: f.write(json.dumps(default_config, indent=4, ensure_ascii=False)) self.login_stat = default_config else: with open(self.login_stat_path, "r", encoding="utf-8") as f: self.login_stat = json.loads(f.read()) self.login_time = self.login_stat.get("login_time", 0) self.login_device_id = self.login_stat.get("device_id", "") def check(self, second: int) -> bool: """检查是否在指定时间内,风控保护。 Args: second (int): 指定的秒数 Returns: bool: 如果当前时间与上次登录时间的差小于指定秒数,返回True;否则返回False """ now = datetime.now().timestamp() return now - self.login_time < second def update_login_status(self, device_id: str = ""): """更新登录状态。 如果设备ID发生变化,更新登录时间和设备ID,并保存到文件。 Args: device_id (str, optional): 设备ID. Defaults to "". """ if device_id == self.login_device_id: return self.login_time = int(datetime.now().timestamp()) self.login_stat["login_time"] = self.login_time self.login_stat["device_id"] = device_id with open(self.login_stat_path, "w", encoding="utf-8") as f: f.write(json.dumps(self.login_stat, indent=4, ensure_ascii=False)) protector = Protect() ================================================ FILE: WechatAPI/Client/tool.py ================================================ import base64 import io import os import aiohttp import pysilk from pydub import AudioSegment from .base import * from .protect import protector from ..errors import * class ToolMixin(WechatAPIClientBase): async def download_image(self, aeskey: str, cdnmidimgurl: str) -> str: """CDN下载高清图片。 Args: aeskey (str): 图片的AES密钥 cdnmidimgurl (str): 图片的CDN URL Returns: str: 图片的base64编码字符串 Raises: UserLoggedOut: 未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "AesKey": aeskey, "Cdnmidimgurl": cdnmidimgurl} response = await session.post(f'http://{self.ip}:{self.port}/CdnDownloadImg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data") else: self.error_handler(json_resp) async def download_voice(self, msg_id: str, voiceurl: str, length: int) -> str: """下载语音文件。 Args: msg_id (str): 消息的msgid voiceurl (str): 语音的url,从xml获取 length (int): 语音长度,从xml获取 Returns: str: 语音的base64编码字符串 Raises: UserLoggedOut: 未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "MsgId": msg_id, "Voiceurl": voiceurl, "Length": length} response = await session.post(f'http://{self.ip}:{self.port}/DownloadVoice', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("data").get("buffer") else: self.error_handler(json_resp) async def download_attach(self, attach_id: str) -> dict: """下载附件。 Args: attach_id (str): 附件ID Returns: dict: 附件数据 Raises: UserLoggedOut: 未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "AttachId": attach_id} response = await session.post(f'http://{self.ip}:{self.port}/DownloadAttach', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("data").get("buffer") else: self.error_handler(json_resp) async def download_video(self, msg_id) -> str: """下载视频。 Args: msg_id (str): 消息的msg_id Returns: str: 视频的base64编码字符串 Raises: UserLoggedOut: 未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "MsgId": msg_id} response = await session.post(f'http://{self.ip}:{self.port}/DownloadVideo', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("data").get("buffer") else: self.error_handler(json_resp) async def set_step(self, count: int) -> bool: """设置步数。 Args: count (int): 要设置的步数 Returns: bool: 成功返回True,失败返回False Raises: UserLoggedOut: 未登录时调用 BanProtection: 风控保护: 新设备登录后4小时内请挂机 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "StepCount": count} response = await session.post(f'http://{self.ip}:{self.port}/SetStep', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp) async def set_proxy(self, proxy: Proxy) -> bool: """设置代理。 Args: proxy (Proxy): 代理配置对象 Returns: bool: 成功返回True,失败返回False Raises: UserLoggedOut: 未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Proxy": {"ProxyIp": f"{proxy.ip}:{proxy.port}", "ProxyUser": proxy.username, "ProxyPassword": proxy.password}} response = await session.post(f'http://{self.ip}:{self.port}/SetProxy', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp) async def check_database(self) -> bool: """检查数据库状态。 Returns: bool: 数据库正常返回True,否则返回False """ async with aiohttp.ClientSession() as session: response = await session.get(f'http://{self.ip}:{self.port}/CheckDatabaseOK') json_resp = await response.json() if json_resp.get("Running"): return True else: return False @staticmethod def base64_to_file(base64_str: str, file_name: str, file_path: str) -> bool: """将base64字符串转换为文件并保存。 Args: base64_str (str): base64编码的字符串 file_name (str): 要保存的文件名 file_path (str): 文件保存路径 Returns: bool: 转换成功返回True,失败返回False """ try: os.makedirs(file_path, exist_ok=True) # 拼接完整的文件路径 full_path = os.path.join(file_path, file_name) # 移除可能存在的 base64 头部信息 if ',' in base64_str: base64_str = base64_str.split(',')[1] # 解码 base64 并写入文件 with open(full_path, 'wb') as f: f.write(base64.b64decode(base64_str)) return True except Exception as e: return False @staticmethod def file_to_base64(file_path: str) -> str: """将文件转换为base64字符串。 Args: file_path (str): 文件路径 Returns: str: base64编码的字符串 """ with open(file_path, 'rb') as f: return base64.b64encode(f.read()).decode() @staticmethod def base64_to_byte(base64_str: str) -> bytes: """将base64字符串转换为bytes。 Args: base64_str (str): base64编码的字符串 Returns: bytes: 解码后的字节数据 """ # 移除可能存在的 base64 头部信息 if ',' in base64_str: base64_str = base64_str.split(',')[1] return base64.b64decode(base64_str) @staticmethod def byte_to_base64(byte: bytes) -> str: """将bytes转换为base64字符串。 Args: byte (bytes): 字节数据 Returns: str: base64编码的字符串 """ return base64.b64encode(byte).decode("utf-8") @staticmethod async def silk_byte_to_byte_wav_byte(silk_byte: bytes) -> bytes: """将silk字节转换为wav字节。 Args: silk_byte (bytes): silk格式的字节数据 Returns: bytes: wav格式的字节数据 """ return await pysilk.async_decode(silk_byte, to_wav=True) @staticmethod def wav_byte_to_amr_byte(wav_byte: bytes) -> bytes: """将WAV字节数据转换为AMR格式。 Args: wav_byte (bytes): WAV格式的字节数据 Returns: bytes: AMR格式的字节数据 Raises: Exception: 转换失败时抛出异常 """ try: # 从字节数据创建 AudioSegment 对象 audio = AudioSegment.from_wav(io.BytesIO(wav_byte)) # 设置 AMR 编码的标准参数 audio = audio.set_frame_rate(8000).set_channels(1) # 创建一个字节缓冲区来存储 AMR 数据 output = io.BytesIO() # 导出为 AMR 格式 audio.export(output, format="amr") # 获取字节数据 return output.getvalue() except Exception as e: raise Exception(f"转换WAV到AMR失败: {str(e)}") @staticmethod def wav_byte_to_amr_base64(wav_byte: bytes) -> str: """将WAV字节数据转换为AMR格式的base64字符串。 Args: wav_byte (bytes): WAV格式的字节数据 Returns: str: AMR格式的base64编码字符串 """ return base64.b64encode(ToolMixin.wav_byte_to_amr_byte(wav_byte)).decode() @staticmethod async def wav_byte_to_silk_byte(wav_byte: bytes) -> bytes: """将WAV字节数据转换为silk格式。 Args: wav_byte (bytes): WAV格式的字节数据 Returns: bytes: silk格式的字节数据 """ # get pcm data audio = AudioSegment.from_wav(io.BytesIO(wav_byte)) pcm = audio.raw_data return await pysilk.async_encode(pcm, data_rate=audio.frame_rate, sample_rate=audio.frame_rate) @staticmethod async def wav_byte_to_silk_base64(wav_byte: bytes) -> str: """将WAV字节数据转换为silk格式的base64字符串。 Args: wav_byte (bytes): WAV格式的字节数据 Returns: str: silk格式的base64编码字符串 """ return base64.b64encode(await ToolMixin.wav_byte_to_silk_byte(wav_byte)).decode() @staticmethod async def silk_base64_to_wav_byte(silk_base64: str) -> bytes: """将silk格式的base64字符串转换为WAV字节数据。 Args: silk_base64 (str): silk格式的base64编码字符串 Returns: bytes: WAV格式的字节数据 """ return await ToolMixin.silk_byte_to_byte_wav_byte(base64.b64decode(silk_base64)) ================================================ FILE: WechatAPI/Client/user.py ================================================ import aiohttp from .base import * from .protect import protector from ..errors import * class UserMixin(WechatAPIClientBase): async def get_profile(self, wxid: str = None) -> dict: """获取用户信息。 Args: wxid (str, optional): 用户wxid. Defaults to None. Returns: dict: 用户信息字典 Raises: UserLoggedOut: 未登录时调用 根据error_handler处理错误 """ if not self.wxid and not wxid: raise UserLoggedOut("请先登录") if not wxid: wxid = self.wxid async with aiohttp.ClientSession() as session: json_param = {"Wxid": wxid} response = await session.post(f'http://{self.ip}:{self.port}/GetProfile', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("userInfo") else: self.error_handler(json_resp) async def get_my_qrcode(self, style: int = 0) -> str: """获取个人二维码。 Args: style (int, optional): 二维码样式. Defaults to 0. Returns: str: 图片的base64编码字符串 Raises: UserLoggedOut: 未登录时调用 BanProtection: 风控保护: 新设备登录后4小时内请挂机 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") elif protector.check(14400) and not self.ignore_protect: raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Style": style} response = await session.post(f'http://{self.ip}:{self.port}/GetMyQRCode', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("qrcode").get("buffer") else: self.error_handler(json_resp) async def is_logged_in(self, wxid: str = None) -> bool: """检查是否登录。 Args: wxid (str, optional): 用户wxid. Defaults to None. Returns: bool: 已登录返回True,未登录返回False """ if not wxid: wxid = self.wxid try: await self.get_profile(wxid) return True except: return False ================================================ FILE: WechatAPI/Server/WechatAPIServer.py ================================================ import asyncio import os import pathlib import xywechatpad_binary from loguru import logger class WechatAPIServer: def __init__(self): self.executable_path = xywechatpad_binary.copy_binary(pathlib.Path(__file__).parent.parent / "core") self.executable_path = self.executable_path.absolute() self.server_task = None self.log_task = None self.process = None async def start(self, port=9000, mode="release", redis_host="127.0.0.1", redis_port=6379, redis_password="", redis_db=0): """异步启动服务""" command = [ self.executable_path, "-p", str(port), "-m", mode, "-rh", redis_host, "-rp", str(redis_port), "-rpwd", redis_password, "-rdb", str(redis_db) ] # 使用异步创建子进程 self.process = await asyncio.create_subprocess_exec( *command, cwd=os.path.dirname(os.path.abspath(__file__)), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) # 启动日志监控任务 self.log_task = asyncio.create_task(self.process_log()) async def stop(self): """异步停止服务""" if hasattr(self, 'process'): try: if not self.log_task.done(): self.log_task.cancel() await asyncio.gather(self.log_task, return_exceptions=True) self.log_task = None self.process.terminate() await self.process.wait() except ProcessLookupError: logger.warning("尝试终止已退出的进程") async def process_log(self): """处理子进程的日志输出""" try: # 创建两个独立的任务来分别处理stdout和stderr stdout_task = asyncio.create_task(self._read_stream(self.process.stdout, "info")) stderr_task = asyncio.create_task(self._read_stream(self.process.stderr, "error")) # 等待两个任务中的任何一个完成 codes = await asyncio.gather(stdout_task, stderr_task, return_exceptions=True) logger.info(f"WechatAPI已退出,返回码: {codes[0]}") except asyncio.CancelledError: pass except Exception as e: logger.error(f"处理日志时发生错误: {e}") async def _read_stream(self, stream, log_level): """读取流并记录日志""" while True: line = await stream.readline() if not line: # EOF if self.process.returncode is not None: return self.process.returncode await asyncio.sleep(0.1) continue text = line.decode('utf-8', errors='replace').strip() if text: if log_level == "info": logger.log("API", text) else: logger.log("API", text) return self.process.returncode wechat_api_server = WechatAPIServer() ================================================ FILE: WechatAPI/Server/__init__.py ================================================ ================================================ FILE: WechatAPI/__init__.py ================================================ from WechatAPI.Server.WechatAPIServer import * from WechatAPI.Client import * from WechatAPI.errors import * __name__ = "WechatAPI" __version__ = "1.0.0" __description__ = "Wechat API for XYBot" __author__ = "HenryXiaoYang" ================================================ FILE: WechatAPI/core/placeholder ================================================ ================================================ FILE: WechatAPI/errors.py ================================================ class MarshallingError(Exception): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) class UnmarshallingError(Exception): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) class MMTLSError(Exception): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) class PacketError(Exception): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) class ParsePacketError(Exception): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) class DatabaseError(Exception): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) class LoginError(Exception): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) class UserLoggedOut(Exception): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) class BanProtection(Exception): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) ================================================ FILE: WechatAPIDocs/Makefile ================================================ # Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) ================================================ FILE: WechatAPIDocs/_templates/custom-toc.html ================================================ ================================================ FILE: WechatAPIDocs/build.sh ================================================ #!/bin/bash cd "$(dirname "$0")" || exit make clean make html rm -rf ../docs/WechatAPIClient/* cp -r _build/html/* ../docs/WechatAPIClient || exit ================================================ FILE: WechatAPIDocs/conf.py ================================================ # Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information import os import sys import sphinx_rtd_theme sys.path.insert(0, os.path.abspath('..')) html_theme = 'furo' project = 'WechatAPI' copyright = '2025, HenryXiaoYang' author = 'HenryXiaoYang' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.napoleon', # 支持Google风格的文档字符串 'sphinx.ext.viewcode' ] templates_path = ['_templates'] exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] language = 'zh_CN' # Napoleon设置 napoleon_google_docstring = True napoleon_numpy_docstring = False napoleon_include_init_with_doc = True napoleon_include_private_with_doc = True napoleon_include_special_with_doc = True napoleon_use_admonition_for_examples = True napoleon_use_admonition_for_notes = True napoleon_use_admonition_for_references = True napoleon_use_ivar = True napoleon_use_param = True napoleon_use_rtype = True napoleon_type_aliases = None html_title = "XYBotV2" html_sidebars = { "**": [ "sidebar/scroll-start.html", "sidebar/brand.html", "sidebar/search.html", "sidebar/navigation.html", "custom-toc.html", "sidebar/scroll-end.html" ] } # furo主题配置 html_theme_options = { "sidebar_hide_name": False, "light_css_variables": { "color-brand-primary": "#2962ff", "color-brand-content": "#2962ff", }, "navigation_with_keys": True, } # 重要函数导航列表 important_functions = { "登录": [ ("检查WechatAPI是否在运行", "index.html#WechatAPI.Client.login.LoginMixin.is_running"), ("获取登录二维码", "index.html#WechatAPI.Client.login.LoginMixin.get_qr_code"), ("二次登录(唤醒登录)", "index.html#WechatAPI.Client.login.LoginMixin.awaken_login"), ("检查登录的UUID状态", "index.html#WechatAPI.Client.login.LoginMixin.check_login_uuid"), ("获取登录缓存信息", "index.html#WechatAPI.Client.login.LoginMixin.get_cached_info"), ("登出当前账号", "index.html#WechatAPI.Client.login.LoginMixin.log_out"), ("发送心跳包", "index.html#WechatAPI.Client.login.LoginMixin.heartbeat"), ("开始自动心跳", "index.html#WechatAPI.Client.login.LoginMixin.start_auto_heartbeat"), ("停止自动心跳", "index.html#WechatAPI.Client.login.LoginMixin.stop_auto_heartbeat"), ("获取自动心跳状态", "index.html#WechatAPI.Client.login.LoginMixin.get_auto_heartbeat_status") ], "消息": [ ("同步消息", "index.html#WechatAPI.Client.message.MessageMixin.sync_message"), ("发送文本消息", "index.html#WechatAPI.Client.message.MessageMixin.send_text_message"), ("发送图片消息", "index.html#WechatAPI.Client.message.MessageMixin.send_image_message"), ("发送语音消息", "index.html#WechatAPI.Client.message.MessageMixin.send_voice_message"), ("发送视频消息", "index.html#WechatAPI.Client.message.MessageMixin.send_video_message"), ("发送链接消息", "index.html#WechatAPI.Client.message.MessageMixin.send_link_message"), ("发送名片消息", "index.html#WechatAPI.Client.message.MessageMixin.send_card_message"), ("发送应用(xml)消息", "index.html#WechatAPI.Client.message.MessageMixin.send_app_message"), ("发送表情消息", "index.html#WechatAPI.Client.message.MessageMixin.send_emoji_message"), ("转发图片消息", "index.html#WechatAPI.Client.message.MessageMixin.send_cdn_img_msg"), ("转发视频消息", "index.html#WechatAPI.Client.message.MessageMixin.send_cdn_video_msg"), ("转发文件消息", "index.html#WechatAPI.Client.message.MessageMixin.send_cdn_file_msg"), ("撤回消息", "index.html#WechatAPI.Client.message.MessageMixin.revoke_message") ], "用户": [ ("获取个人二维码", "index.html#WechatAPI.Client.user.UserMixin.get_my_qrcode"), ("获取用户信息", "index.html#WechatAPI.Client.user.UserMixin.get_profile"), ("检查是否登录", "index.html#WechatAPI.Client.user.UserMixin.is_logged_in") ], "群聊": [ ("获取群聊信息", "index.html#WechatAPI.Client.chatroom.ChatroomMixin.get_chatroom_info"), ("获取群聊公告", "index.html#WechatAPI.Client.chatroom.ChatroomMixin.get_chatroom_announce"), ("获取群聊成员列表", "index.html#WechatAPI.Client.chatroom.ChatroomMixin.get_chatroom_member_list"), ("获取群聊二维码", "index.html#WechatAPI.Client.chatroom.ChatroomMixin.get_chatroom_qrcode"), ("添加群成员(群聊最多40人)", "index.html#WechatAPI.Client.chatroom.ChatroomMixin.add_chatroom_member"), ("邀请群聊成员(群聊大于40人)", "index.html#WechatAPI.Client.chatroom.ChatroomMixin.invite_chatroom_member") ], "好友": [ ("获取联系人信息", "index.html#WechatAPI.Client.friend.FriendMixin.get_contact"), ("获取联系人详情", "index.html#WechatAPI.Client.friend.FriendMixin.get_contract_detail"), ("获取联系人列表", "index.html#WechatAPI.Client.friend.FriendMixin.get_contract_list"), ("获取用户昵称", "index.html#WechatAPI.Client.friend.FriendMixin.get_nickname"), ("接受好友请求", "index.html#WechatAPI.Client.friend.FriendMixin.accept_friend"), ], "红包": [ ("获取红包详情", "index.html#WechatAPI.Client.hongbao.HongBaoMixin.get_hongbao_detail") ], "工具": [ ("检查数据库状态", "index.html#WechatAPI.Client.tool.ToolMixin.check_database"), ("设置步数", "index.html#WechatAPI.Client.tool.ToolMixin.set_step"), ("下载高清图片", "index.html#WechatAPI.Client.tool.ToolMixin.download_image"), ("下载视频", "index.html#WechatAPI.Client.tool.ToolMixin.download_video"), ("下载语音文件", "index.html#WechatAPI.Client.tool.ToolMixin.download_voice"), ("下载附件", "index.html#WechatAPI.Client.tool.ToolMixin.download_attach"), ("base64转字节", "index.html#WechatAPI.Client.tool.ToolMixin.base64_to_byte"), ("base64转文件", "index.html#WechatAPI.Client.tool.ToolMixin.base64_to_file"), ("字节转base64", "index.html#WechatAPI.Client.tool.ToolMixin.byte_to_base64"), ("文件转base64", "index.html#WechatAPI.Client.tool.ToolMixin.file_to_base64"), ("silk的base64转wav字节", "index.html#WechatAPI.Client.tool.ToolMixin.silk_base64_to_wav_byte"), ("silk字节转wav字节", "index.html#WechatAPI.Client.tool.ToolMixin.silk_byte_to_byte_wav_byte"), ("WAV字节转AMR的base64", "index.html#WechatAPI.Client.tool.ToolMixin.wav_byte_to_amr_base64"), ("WAV字节转AMR字节", "index.html#WechatAPI.Client.tool.ToolMixin.wav_byte_to_amr_byte"), ("WAV字节转silk的base64", "index.html#WechatAPI.Client.tool.ToolMixin.wav_byte_to_silk_base64"), ("WAV字节转silk字节", "index.html#WechatAPI.Client.tool.ToolMixin.wav_byte_to_silk_byte"), ] } # 添加 HTML 上下文 html_context = { 'important_functions': important_functions } ================================================ FILE: WechatAPIDocs/index.rst ================================================ WechatAPIClient ------------------------------ 基础 ~~~~~~~ .. automodule:: WechatAPI.Client.base :members: :undoc-members: :show-inheritance: 登录 ~~~~~~~ .. automodule:: WechatAPI.Client.login :members: :undoc-members: :show-inheritance: 消息 ~~~~~~~ .. automodule:: WechatAPI.Client.message :members: :undoc-members: :show-inheritance: 用户 ~~~~~~~ .. automodule:: WechatAPI.Client.user :members: :undoc-members: :show-inheritance: 群聊 ~~~~~~~ .. automodule:: WechatAPI.Client.chatroom :members: :undoc-members: :show-inheritance: 好友 ~~~~~~~ .. automodule:: WechatAPI.Client.friend :members: :undoc-members: :show-inheritance: 红包 ~~~~~~~ .. automodule:: WechatAPI.Client.hongbao :members: :undoc-members: :show-inheritance: 保护 ~~~~~~~ .. automodule:: WechatAPI.Client.protect :members: :undoc-members: :show-inheritance: 工具 ~~~~~~~ .. automodule:: WechatAPI.Client.tool :members: :undoc-members: :show-inheritance: 索引 ==== * :ref:`search` ================================================ FILE: WechatAPIDocs/make.bat ================================================ @ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.https://www.sphinx-doc.org/ exit /b 1 ) if "%1" == "" goto help %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd ================================================ FILE: WechatAPIDocs/requirements.txt ================================================ sphinx~=8.1.3 sphinx-rtd-theme~=3.0.2 furo~=2024.8.6 ================================================ FILE: XYBot_Dify_Template.yml ================================================ app: description: XYBot微信机器人Dify插件模版 icon: 🤖 icon_background: '#FFEAD5' mode: advanced-chat name: XYBot use_icon_as_answer_icon: false kind: app version: 0.1.5 workflow: conversation_variables: [ ] environment_variables: [ ] features: file_upload: allowed_file_extensions: [ ] allowed_file_types: - image allowed_file_upload_methods: - remote_url - local_file enabled: true fileUploadConfig: audio_file_size_limit: 50 batch_count_limit: 5 file_size_limit: 15 image_file_size_limit: 10 video_file_size_limit: 100 workflow_file_upload_limit: 10 image: enabled: false number_limits: 3 transfer_methods: - local_file - remote_url number_limits: 1 opening_statement: '' retriever_resource: enabled: true sensitive_word_avoidance: enabled: false speech_to_text: enabled: true suggested_questions: [ ] suggested_questions_after_answer: enabled: false text_to_speech: enabled: true language: zh-Hans voice: '' graph: edges: - data: isInIteration: false sourceType: llm targetType: answer id: 1738917745853-source-1738918123165-target source: '1738917745853' sourceHandle: source target: '1738918123165' targetHandle: target type: custom zIndex: 0 - data: isInIteration: false sourceType: start targetType: if-else id: 1738915019767-source-1739252680159-target source: '1738915019767' sourceHandle: source target: '1739252680159' targetHandle: target type: custom zIndex: 0 - data: isInIteration: false sourceType: if-else targetType: llm id: 1739252680159-3a9541e0-b608-4bb5-a027-def256809e7a-1739252840296-target source: '1739252680159' sourceHandle: 3a9541e0-b608-4bb5-a027-def256809e7a target: '1739252840296' targetHandle: target type: custom zIndex: 0 - data: isInIteration: false sourceType: llm targetType: answer id: 1739252840296-source-1739252888579-target source: '1739252840296' sourceHandle: source target: '1739252888579' targetHandle: target type: custom zIndex: 0 - data: isInIteration: false sourceType: list-operator targetType: tool id: 1739254087701-source-1739252789163-target source: '1739254087701' sourceHandle: source target: '1739252789163' targetHandle: target type: custom zIndex: 0 - data: isInIteration: false sourceType: if-else targetType: list-operator id: 1739252680159-458858e2-e643-442d-9bfd-2ef164189378-1739254087701-target source: '1739252680159' sourceHandle: 458858e2-e643-442d-9bfd-2ef164189378 target: '1739254087701' targetHandle: target type: custom zIndex: 0 - data: isInIteration: false sourceType: if-else targetType: llm id: 1739252680159-true-1738917745853-target source: '1739252680159' sourceHandle: 'true' target: '1738917745853' targetHandle: target type: custom zIndex: 0 - data: isInIteration: false sourceType: tool targetType: llm id: 1739252789163-source-1739255059499-target source: '1739252789163' sourceHandle: source target: '1739255059499' targetHandle: target type: custom zIndex: 0 - data: isInIteration: false sourceType: llm targetType: tool id: 1739255059499-source-1739257224364-target source: '1739255059499' sourceHandle: source target: '1739257224364' targetHandle: target type: custom zIndex: 0 - data: isInIteration: false sourceType: tool targetType: answer id: 1739257224364-source-1739288133732-target source: '1739257224364' sourceHandle: source target: '1739288133732' targetHandle: target type: custom zIndex: 0 nodes: - data: desc: '' selected: false title: 开始 type: start variables: [ ] height: 54 id: '1738915019767' position: x: -262.276922445997 y: 406.16303131970574 positionAbsolute: x: -262.276922445997 y: 406.16303131970574 selected: false sourcePosition: right targetPosition: left type: custom width: 244 - data: context: enabled: false variable_selector: [ ] desc: '' memory: query_prompt_template: '{{#sys.query#}} ' role_prefix: assistant: '' user: '' window: enabled: false size: 50 model: completion_params: max_tokens: 4096 temperature: 0.7 mode: chat name: gpt-4o-mini provider: openai prompt_template: - edition_type: basic id: 6de35274-53f5-4e34-89c5-a9c30ffb5f64 role: system text: '你是一个友好有用的助理,你的名字叫XYBot。你会用简洁、专业的方式回答问题。 - 用户用什么语言问,就用什么语言回答 - 对于代码相关问题,请提供清晰的示例和解释' selected: false title: LLM1 type: llm variables: [ ] vision: configs: detail: low variable_selector: - sys - files enabled: false height: 98 id: '1738917745853' position: x: 500.2723150319407 y: 356.991212440781 positionAbsolute: x: 500.2723150319407 y: 356.991212440781 selected: false sourcePosition: right targetPosition: left type: custom width: 244 - data: answer: '{{#1738917745853.text#}}' desc: '' selected: false title: 直接回复 type: answer variables: [ ] height: 103 id: '1738918123165' position: x: 829.4029907903282 y: 356.991212440781 positionAbsolute: x: 829.4029907903282 y: 356.991212440781 selected: false sourcePosition: right targetPosition: left type: custom width: 244 - data: cases: - case_id: 'true' conditions: - comparison_operator: empty id: a6c51713-13ff-4c59-92ad-df1af61aa759 value: '' varType: array[file] variable_selector: - sys - files id: 'true' logical_operator: or - case_id: 3a9541e0-b608-4bb5-a027-def256809e7a conditions: - comparison_operator: contains id: dda26e42-905b-4503-9e7e-aefe52b20e7e sub_variable_condition: case_id: a3472907-6671-4ff6-a40f-420943e154f3 conditions: - comparison_operator: in id: f2c55058-eabf-4fde-a2da-e6c07e64246c key: type value: - image varType: string logical_operator: and value: '' varType: array[file] variable_selector: - sys - files id: 3a9541e0-b608-4bb5-a027-def256809e7a logical_operator: and - case_id: 458858e2-e643-442d-9bfd-2ef164189378 conditions: - comparison_operator: contains id: d5b09e7e-a8ea-4018-8650-947d6d9b0ca1 sub_variable_condition: case_id: 1e38440a-5660-41d5-9be3-7cfafa0a8b6a conditions: - comparison_operator: in id: 864efcf6-b80a-4c3b-8842-b3c7c66074cb key: type value: - audio varType: string logical_operator: and value: '' varType: array[file] variable_selector: - sys - files id: 458858e2-e643-442d-9bfd-2ef164189378 logical_operator: and - case_id: c47afde9-f052-4d4d-9efe-f498b3f94a80 conditions: - comparison_operator: contains id: 1a5eab00-9ed2-488c-8eb3-99d902c33ac3 sub_variable_condition: case_id: b4eb1a99-7840-4d62-bffe-e40a073db948 conditions: - comparison_operator: in id: 280c0d9a-7e77-4c69-902c-36e9ccc466be key: type value: - document varType: string logical_operator: and value: '' varType: array[file] variable_selector: - sys - files logical_operator: and - case_id: 5e77a038-54bc-45a9-b99f-1606c8d13d6a conditions: - comparison_operator: contains id: 33cb4ff7-f570-46b8-a358-2dc44e735754 sub_variable_condition: case_id: 84bf66f6-5445-46ce-b036-90207abb4a98 conditions: - comparison_operator: in id: dc9f8cb7-668b-4545-b924-f58e6bbd4436 key: type value: - video varType: string logical_operator: and value: '' varType: array[file] variable_selector: - sys - files logical_operator: and desc: '' selected: false title: 条件分支 type: if-else height: 414 id: '1739252680159' position: x: 92.72226561832565 y: 406.16303131970574 positionAbsolute: x: 92.72226561832565 y: 406.16303131970574 selected: false sourcePosition: right targetPosition: left type: custom width: 244 - data: desc: '' provider_id: audio provider_name: audio provider_type: builtin selected: false title: Speech To Text tool_configurations: model: openai_api_compatible#step-asr tool_label: Speech To Text tool_name: asr tool_parameters: audio_file: type: variable value: - '1739254087701' - first_record type: tool height: 90 id: '1739252789163' position: x: 818.0854081792563 y: 637.9065614866665 positionAbsolute: x: 818.0854081792563 y: 637.9065614866665 selected: false sourcePosition: right targetPosition: left type: custom width: 244 - data: context: enabled: false variable_selector: [ ] desc: '' memory: query_prompt_template: '{{#sys.query#}}' role_prefix: assistant: '' user: '' window: enabled: false size: 50 model: completion_params: temperature: 0.7 mode: chat name: gpt-4o-mini provider: openai prompt_template: - id: 230c6990-e5b0-41d7-9dc8-70782d28bd8d role: system text: 你是一个乐于助人的助手。请将图片里的内容完整无缺的复述出来。 selected: true title: 支持图片输入的LLM type: llm variables: [ ] vision: configs: detail: high variable_selector: - sys - files enabled: true height: 98 id: '1739252840296' position: x: 507.5756009542859 y: 498.0040880918219 positionAbsolute: x: 507.5756009542859 y: 498.0040880918219 selected: true sourcePosition: right targetPosition: left type: custom width: 244 - data: answer: '{{#1739252840296.text#}}' desc: '' selected: false title: 直接回复 3 type: answer variables: [ ] height: 103 id: '1739252888579' position: x: 818.0854081792563 y: 498.0040880918219 positionAbsolute: x: 818.0854081792563 y: 498.0040880918219 selected: false sourcePosition: right targetPosition: left type: custom width: 244 - data: desc: '' extract_by: enabled: true serial: '1' filter_by: conditions: - comparison_operator: contains key: name value: '' enabled: false item_var_type: file limit: enabled: false size: 1 order_by: enabled: false key: '' value: asc selected: false title: 列表操作 type: list-operator var_type: array[file] variable: - sys - files height: 92 id: '1739254087701' position: x: 507.5756009542859 y: 630.2109361546167 positionAbsolute: x: 507.5756009542859 y: 630.2109361546167 selected: false sourcePosition: right targetPosition: left type: custom width: 244 - data: context: enabled: false variable_selector: [ ] desc: '' memory: query_prompt_template: '{{#sys.query#}} {{#1739252789163.text#}}' role_prefix: assistant: '' user: '' window: enabled: false size: 50 model: completion_params: temperature: 0.7 mode: chat name: gpt-4o-mini provider: openai prompt_template: - id: 6291138d-3a76-46e1-a7e0-d2670d6352f5 role: system text: 你是一个友好的助理,你的名字叫XYBot。你会用简洁方式回答问题。 selected: false title: LLM type: llm variables: [ ] vision: enabled: false height: 98 id: '1739255059499' position: x: 1115.57540385159 y: 637.9065614866665 positionAbsolute: x: 1115.57540385159 y: 637.9065614866665 selected: false sourcePosition: right targetPosition: left type: custom width: 244 - data: desc: '' provider_id: audio provider_name: audio provider_type: builtin selected: false title: Text To Speech tool_configurations: model: openai_api_compatible#step-tts-mini voice#openai#tts-1: null voice#openai#tts-1-hd: null voice#openai_api_compatible#step-tts-mini: qingniandaxuesheng voice#siliconflow#fishaudio/fish-speech-1.4: null voice#siliconflow#fishaudio/fish-speech-1.5: null tool_label: Text To Speech tool_name: tts tool_parameters: text: type: mixed value: '{{#1739255059499.text#}}' type: tool height: 220 id: '1739257224364' position: x: 1397.2265350019834 y: 637.9065614866665 positionAbsolute: x: 1397.2265350019834 y: 637.9065614866665 selected: false sourcePosition: right targetPosition: left type: custom width: 244 - data: answer: '{{#1739257224364.files#}}' desc: '' selected: false title: 直接回复 3 type: answer variables: [ ] height: 103 id: '1739288133732' position: x: 1701.2265350019834 y: 637.9065614866665 positionAbsolute: x: 1701.2265350019834 y: 637.9065614866665 selected: false sourcePosition: right targetPosition: left type: custom width: 244 viewport: x: 166.25140302119496 y: -208.44690452784948 zoom: 0.6997797002524726 ================================================ FILE: app.py ================================================ import asyncio import os import signal import sys from loguru import logger # 在任何导入之前设置日志级别 logger.remove() logger.level("WEBUI", no=20, color="") logger.level("API", no=1, color="") logger.add(sys.stdout, level="INFO", colorize=True, format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}") logger.add("logs/xybot.log", rotation="10mb", level="DEBUG", encoding="utf-8") logger.add("logs/wechatapi.log", level="DEBUG", filter=lambda r: r["level"].name == "API") logger.add("logs/webui.log", level="WEBUI", filter=lambda r: r["level"].name == "WEBUI") # 导入eventlet并应用猴子补丁 import eventlet eventlet.monkey_patch() # 现在可以安全地导入其他模块 from WebUI import create_app from WebUI.services.websocket_service import shutdown_websocket from database.XYBotDB import XYBotDB from database.keyvalDB import KeyvalDB from database.messsagDB import MessageDB # 全局变量 message_db = None keyval_db = None _is_shutting_down = False async def init_system(): """初始化系统数据库连接""" global message_db, keyval_db try: logger.info("正在初始化数据库连接...") XYBotDB() message_db = MessageDB() await message_db.initialize() keyval_db = KeyvalDB() await keyval_db.initialize() await keyval_db.delete("start_time") logger.success("数据库初始化成功") except Exception as e: logger.error(f"数据库初始化失败: {str(e)}") raise async def shutdown_system(): """关闭系统连接和资源""" global _is_shutting_down, message_db, keyval_db if _is_shutting_down: return _is_shutting_down = True logger.info("正在关闭系统资源...") # 关闭WebSocket服务 try: shutdown_websocket() except Exception as e: logger.error(f"关闭WebSocket服务时出错: {str(e)}") # 关闭数据库连接 logger.info("正在关闭数据库连接...") for db, name in [(message_db, "消息数据库"), (keyval_db, "键值数据库")]: if db: try: await db.close() logger.info(f"{name}连接已关闭") except Exception as e: logger.error(f"关闭{name}连接时出错: {str(e)}") message_db = keyval_db = None logger.success("所有系统资源已关闭") def run_async_safely(coro): """安全运行异步协程,处理事件循环问题 Args: coro: 要运行的异步协程 """ try: try: loop = asyncio.get_event_loop() except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) if loop.is_running(): asyncio.run_coroutine_threadsafe(coro, loop).result(timeout=5) else: loop.run_until_complete(coro) except Exception as e: logger.error(f"异步任务执行失败: {str(e)}") def signal_handler(signum, _): """处理终止信号 Args: signum: 信号编号 _: 信号帧(未使用) """ logger.info(f"收到 {signum} 信号, 退出中...") run_async_safely(shutdown_system()) sys.exit(0) # 注册信号处理器 signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) def main(): """应用主入口函数""" try: # 初始化系统 asyncio.run(init_system()) # 创建Flask应用和socketio app, socketio = create_app() # 运行Web服务器 host = os.environ.get('FLASK_HOST', '0.0.0.0') port = int(os.environ.get('FLASK_PORT', 9999)) debug = False # 禁用debug模式以防止双重初始化 logger.info(f"WebUI服务启动于 http://{host}:{port}/") socketio.run(app, host=host, port=port, debug=debug) except KeyboardInterrupt: logger.info("接收到中断信号,开始优雅关闭...") except Exception as e: logger.error(f"应用运行时发生错误: {str(e)}") finally: run_async_safely(shutdown_system()) if __name__ == '__main__': main() ================================================ FILE: bot.py ================================================ import asyncio import json import os import sys import time import tomllib import traceback from pathlib import Path from loguru import logger import WechatAPI from WebUI.common.bot_bridge import bot_bridge from WechatAPI.Server.WechatAPIServer import wechat_api_server from database.XYBotDB import XYBotDB from database.keyvalDB import KeyvalDB from database.messsagDB import MessageDB from utils.decorators import scheduler from utils.plugin_manager import PluginManager from utils.xybot import XYBot async def run_bot(): """ 机器人主要运行逻辑 """ try: # 设置工作目录 script_dir = Path(__file__).resolve().parent # 读取主设置 config_path = script_dir / "main_config.toml" with open(config_path, "rb") as f: main_config = tomllib.load(f) logger.success("读取主设置成功") # 启动WechatAPI服务 server = wechat_api_server api_config = main_config.get("WechatAPIServer", {}) redis_host = api_config.get("redis-host", "127.0.0.1") redis_port = api_config.get("redis-port", 6379) logger.debug("Redis 主机地址: {}:{}", redis_host, redis_port) await server.start(port=api_config.get("port", 9000), mode=api_config.get("mode", "release"), redis_host=redis_host, redis_port=redis_port, redis_password=api_config.get("redis-password", ""), redis_db=api_config.get("redis-db", 0)) # 实例化WechatAPI客户端 bot = WechatAPI.WechatAPIClient("127.0.0.1", api_config.get("port", 9000)) bot.ignore_protect = main_config.get("XYBot", {}).get("ignore-protection", False) # 等待WechatAPI服务启动 time_out = 10 while not await bot.is_running() and time_out > 0: logger.info("等待WechatAPI启动中") await asyncio.sleep(2) time_out -= 2 if time_out <= 0: logger.error("WechatAPI服务启动超时") return if not await bot.check_database(): logger.error("Redis连接失败,请检查Redis是否在运行中,Redis的配置") return logger.success("WechatAPI服务已启动") # 加载插件目录下的所有插件 plugin_manager = PluginManager() plugin_manager.set_bot(bot) loaded_plugins = await plugin_manager.load_plugins(load_disabled=False) logger.success(f"已加载插件: {loaded_plugins}") # ==========登陆========== # 检查并创建robot_stat.json文件 robot_stat_path = script_dir / "resource" / "robot_stat.json" if not os.path.exists(robot_stat_path): default_config = { "wxid": "", "device_name": "", "device_id": "" } os.makedirs(os.path.dirname(robot_stat_path), exist_ok=True) with open(robot_stat_path, "w") as f: json.dump(default_config, f) robot_stat = default_config else: with open(robot_stat_path, "r") as f: robot_stat = json.load(f) wxid = robot_stat.get("wxid", None) device_name = robot_stat.get("device_name", None) device_id = robot_stat.get("device_id", None) if not await bot.is_logged_in(wxid): while not await bot.is_logged_in(wxid): # 需要登录 try: if await bot.get_cached_info(wxid): # 尝试唤醒登录 uuid = await bot.awaken_login(wxid) logger.success("获取到登录uuid: {}", uuid) else: # 二维码登录 if not device_name: device_name = bot.create_device_name() if not device_id: device_id = bot.create_device_id() uuid, url, qr = await bot.get_qr_code(device_id=device_id, device_name=device_name) logger.success("获取到登录uuid: {}", uuid) logger.success("获取到登录二维码: {}", url) bot_bridge.save_profile(avatar_url=url) logger.info("\n" + qr) except: # 二维码登录 if not device_name: device_name = bot.create_device_name() if not device_id: device_id = bot.create_device_id() uuid, url, qr = await bot.get_qr_code(device_id=device_id, device_name=device_name) logger.success("获取到登录uuid: {}", uuid) logger.success("获取到登录二维码: {}", url) bot_bridge.save_profile(avatar_url=url) logger.info("\n" + qr) while True: stat, data = await bot.check_login_uuid(uuid, device_id=device_id) if stat: break logger.info("等待登录中,过期倒计时:{}", data) await asyncio.sleep(5) # 保存登录信息 robot_stat["wxid"] = bot.wxid robot_stat["device_name"] = device_name robot_stat["device_id"] = device_id with open("resource/robot_stat.json", "w") as f: json.dump(robot_stat, f) # 获取登录账号信息 bot.wxid = data.get("acctSectResp", {}).get("userName", "") bot.nickname = data.get("acctSectResp", {}).get("nickName", "") bot.alias = data.get("acctSectResp", {}).get("alias", "") bot.phone = data.get("acctSectResp", {}).get("bindMobile", "") logger.info("登录账号信息: wxid: {} 昵称: {} 微信号: {} 手机号: {}", bot.wxid, bot.nickname, bot.alias, bot.phone) bot_bridge.save_profile(avatar_url=data.get("userInfoExt", {}).get("BigHeadImgUrl", ""), nickname=data.get("acctSectResp", {}).get("nickName", ""), wxid=data.get("acctSectResp", {}).get("userName", ""), alias=data.get("acctSectResp", {}).get("alias", "")) else: # 已登录 bot.wxid = wxid profile = await bot.get_profile() bot.nickname = profile.get("NickName", {}).get("string", "") bot.alias = profile.get("Alias", "") bot.phone = profile.get("BindMobile", {}).get("string", "") logger.info("登录账号信息: wxid: {} 昵称: {} 微信号: {} 手机号: {}", bot.wxid, bot.nickname, bot.alias, bot.phone) bot_bridge.save_profile(nickname=profile.get("NickName", {}).get("string", ""), wxid=wxid, alias=profile.get("Alias", "")) logger.info("登录设备信息: device_name: {} device_id: {}", device_name, device_id) logger.success("登录成功") # ========== 登录完毕 开始初始化 ========== # # 开启自动心跳 try: success = await bot.start_auto_heartbeat() if success: logger.success("已开启自动心跳") else: logger.warning("开启自动心跳失败") except ValueError: logger.warning("自动心跳已在运行") except Exception as e: if "在运行" not in str(e): logger.warning("自动心跳已在运行") # 初始化机器人 xybot = XYBot(bot) xybot.update_profile(bot.wxid, bot.nickname, bot.alias, bot.phone) # 启动调度器 if scheduler.state == 0: scheduler.start() else: scheduler.remove_all_jobs() logger.success("定时任务已启动") # ========== 开始接受消息 ========== # # 开始接受消息说明机器人开始正常运行 keyval_db = KeyvalDB() await keyval_db.set("start_time", str(int(time.time()))) # 先接受堆积消息 logger.info("处理堆积消息中") count = 0 while True: data = await bot.sync_message() data = data.get("AddMsgs") if not data: if count > 2: break else: count += 1 continue logger.debug("接受到 {} 条消息", len(data)) await asyncio.sleep(1) logger.success("处理堆积消息完毕") logger.success("开始处理消息") while True: try: data = await bot.sync_message() except Exception as e: logger.warning("获取新消息失败 {}", e) await asyncio.sleep(5) continue data = data.get("AddMsgs") if data: for message in data: asyncio.create_task(xybot.process_message(message)) await asyncio.sleep(0.5) except asyncio.CancelledError: await wechat_api_server.stop() logger.info("机器人关闭") except Exception as e: logger.error(f"机器人运行出错: {e}") logger.error(traceback.format_exc()) async def init_system(): """系统初始化""" print( "░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓███████▓▒░ ░▒▓██████▓▒░▒▓████████▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓███████▓▒░ \n" "░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ \n" "░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒▒▓█▓▒░ ░▒▓█▓▒░ \n" " ░▒▓██████▓▒░ ░▒▓██████▓▒░░▒▓███████▓▒░░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒▒▓█▓▒░ ░▒▓██████▓▒░ \n" "░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░ \n" "░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░ \n" "░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓███████▓▒░ ░▒▓██████▓▒░ ░▒▓█▓▒░ ░▒▓██▓▒░ ░▒▓████████▓▒░\n") # 配置日志 logger.remove() logger.level("API", no=1, color="") logger.add( "logs/xybot.log", format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}", encoding="utf-8", enqueue=True, rotation="10mb", retention="2 weeks", backtrace=True, diagnose=True, level="DEBUG", ) logger.add( sys.stdout, colorize=True, format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}", level="TRACE", enqueue=True, backtrace=True, diagnose=True, ) logger.add( "logs/wechatapi.log", format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}", level="API", encoding="utf-8", enqueue=True, rotation="10mb", retention="2 weeks", backtrace=True, diagnose=True, filter=lambda record: record["level"].name == "API", ) # 初始化数据库 XYBotDB() message_db = MessageDB() await message_db.initialize() keyval_db = KeyvalDB() await keyval_db.initialize() await keyval_db.delete("start_time") logger.success("数据库初始化成功") async def main(): """主入口函数""" await init_system() await run_bot() if __name__ == "__main__": if sys.version_info.major != 3 and sys.version_info.minor != 11: print("请使用Python3.11") sys.exit(1) asyncio.run(main()) ================================================ FILE: database/XYBotDB.py ================================================ import datetime import tomllib from concurrent.futures import ThreadPoolExecutor from typing import Union from loguru import logger from sqlalchemy import Column, String, Integer, DateTime, create_engine, JSON, Boolean from sqlalchemy import update from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import declarative_base from sqlalchemy.orm import sessionmaker from utils.singleton import Singleton Base = declarative_base() class User(Base): __tablename__ = 'user' wxid = Column(String(20), primary_key=True, nullable=False, unique=True, index=True, autoincrement=False, comment='wxid') points = Column(Integer, nullable=False, default=0, comment='points') signin_stat = Column(DateTime, nullable=False, default=datetime.datetime.fromtimestamp(0), comment='signin_stat') signin_streak = Column(Integer, nullable=False, default=0, comment='signin_streak') whitelist = Column(Boolean, nullable=False, default=False, comment='whitelist') llm_thread_id = Column(JSON, nullable=False, default=lambda: {}, comment='llm_thread_id') class Chatroom(Base): __tablename__ = 'chatroom' chatroom_id = Column(String(20), primary_key=True, nullable=False, unique=True, index=True, autoincrement=False, comment='chatroom_id') members = Column(JSON, nullable=False, default=list, comment='members') llm_thread_id = Column(JSON, nullable=False, default=lambda: {}, comment='llm_thread_id') class XYBotDB(metaclass=Singleton): def __init__(self): with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) self.database_url = main_config["XYBot"]["XYBotDB-url"] self.engine = create_engine(self.database_url) self.DBSession = sessionmaker(bind=self.engine) # 创建表 Base.metadata.create_all(self.engine) # 创建线程池执行器 self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="database") def _execute_in_queue(self, method, *args, **kwargs): """在队列中执行数据库操作""" future = self.executor.submit(method, *args, **kwargs) try: return future.result(timeout=20) # 20秒超时 except Exception as e: logger.error(f"数据库操作失败: {method.__name__} - {str(e)}") raise # USER def add_points(self, wxid: str, num: int) -> bool: """Thread-safe point addition""" return self._execute_in_queue(self._add_points, wxid, num) def _add_points(self, wxid: str, num: int) -> bool: """Thread-safe point addition""" session = self.DBSession() try: # Use UPDATE with atomic operation result = session.execute( update(User) .where(User.wxid == wxid) .values(points=User.points + num) ) if result.rowcount == 0: # User doesn't exist, create new user = User(wxid=wxid, points=num) session.add(user) logger.info(f"数据库: 用户{wxid}积分增加{num}") session.commit() return True except SQLAlchemyError as e: session.rollback() logger.error(f"数据库: 用户{wxid}积分增加失败, 错误: {e}") return False finally: session.close() def set_points(self, wxid: str, num: int) -> bool: """Thread-safe point setting""" return self._execute_in_queue(self._set_points, wxid, num) def _set_points(self, wxid: str, num: int) -> bool: """Thread-safe point setting""" session = self.DBSession() try: result = session.execute( update(User) .where(User.wxid == wxid) .values(points=num) ) if result.rowcount == 0: user = User(wxid=wxid, points=num) session.add(user) logger.info(f"数据库: 用户{wxid}积分设置为{num}") session.commit() return True except SQLAlchemyError as e: session.rollback() logger.error(f"数据库: 用户{wxid}积分设置失败, 错误: {e}") return False finally: session.close() def get_points(self, wxid: str) -> int: """Get user points""" return self._execute_in_queue(self._get_points, wxid) def _get_points(self, wxid: str) -> int: """Get user points""" session = self.DBSession() try: user = session.query(User).filter_by(wxid=wxid).first() return user.points if user else 0 finally: session.close() def get_signin_stat(self, wxid: str) -> datetime.datetime: """获取用户签到状态""" return self._execute_in_queue(self._get_signin_stat, wxid) def _get_signin_stat(self, wxid: str) -> datetime.datetime: session = self.DBSession() try: user = session.query(User).filter_by(wxid=wxid).first() return user.signin_stat if user else datetime.datetime.fromtimestamp(0) finally: session.close() def set_signin_stat(self, wxid: str, signin_time: datetime.datetime) -> bool: """Thread-safe set user's signin time""" return self._execute_in_queue(self._set_signin_stat, wxid, signin_time) def _set_signin_stat(self, wxid: str, signin_time: datetime.datetime) -> bool: session = self.DBSession() try: result = session.execute( update(User) .where(User.wxid == wxid) .values( signin_stat=signin_time, signin_streak=User.signin_streak ) ) if result.rowcount == 0: user = User( wxid=wxid, signin_stat=signin_time, signin_streak=0 ) session.add(user) logger.info(f"数据库: 用户{wxid}登录时间设置为{signin_time}") session.commit() return True except SQLAlchemyError as e: session.rollback() logger.error(f"数据库: 用户{wxid}登录时间设置失败, 错误: {e}") return False finally: session.close() def reset_all_signin_stat(self) -> bool: """Reset all users' signin status""" session = self.DBSession() try: session.query(User).update({User.signin_stat: datetime.datetime.fromtimestamp(0)}) session.commit() return True except Exception as e: session.rollback() logger.error(f"数据库: 重置所有用户登录时间失败, 错误: {e}") return False finally: session.close() def get_leaderboard(self, count: int) -> list: """Get points leaderboard""" session = self.DBSession() try: users = session.query(User).order_by(User.points.desc()).limit(count).all() return [(user.wxid, user.points) for user in users] finally: session.close() def set_whitelist(self, wxid: str, stat: bool) -> bool: """Set user's whitelist status""" session = self.DBSession() try: user = session.query(User).filter_by(wxid=wxid).first() if not user: user = User(wxid=wxid) session.add(user) user.whitelist = stat session.commit() logger.info(f"数据库: 用户{wxid}白名单状态设置为{stat}") return True except Exception as e: session.rollback() logger.error(f"数据库: 用户{wxid}白名单状态设置失败, 错误: {e}") return False finally: session.close() def get_whitelist(self, wxid: str) -> bool: """Get user's whitelist status""" session = self.DBSession() try: user = session.query(User).filter_by(wxid=wxid).first() return user.whitelist if user else False finally: session.close() def get_whitelist_list(self) -> list: """Get list of all whitelisted users""" session = self.DBSession() try: users = session.query(User).filter_by(whitelist=True).all() return [user.wxid for user in users] finally: session.close() def safe_trade_points(self, trader_wxid: str, target_wxid: str, num: int) -> bool: """Thread-safe points trading between users""" return self._execute_in_queue(self._safe_trade_points, trader_wxid, target_wxid, num) def _safe_trade_points(self, trader_wxid: str, target_wxid: str, num: int) -> bool: """Thread-safe points trading between users""" session = self.DBSession() try: # Start transaction with row-level locking trader = session.query(User).filter_by(wxid=trader_wxid) \ .with_for_update().first() # Acquire row lock target = session.query(User).filter_by(wxid=target_wxid) \ .with_for_update().first() # Acquire row lock if not trader: trader = User(wxid=trader_wxid) session.add(trader) if not target: target = User(wxid=target_wxid) session.add(target) session.flush() # Ensure IDs are generated if trader.points >= num: trader.points -= num target.points += num session.commit() logger.info(f"数据库: 用户{trader_wxid}给用户{target_wxid}转账{num}积分") return True logger.info(f"数据库: 转账失败, 用户{trader_wxid}积分不足") session.rollback() return False except SQLAlchemyError as e: session.rollback() logger.error(f"数据库: 转账失败, 错误: {e}") return False finally: session.close() def get_user_list(self) -> list: """Get list of all users""" session = self.DBSession() try: users = session.query(User).all() return [user.wxid for user in users] finally: session.close() def get_llm_thread_id(self, wxid: str, namespace: str = None) -> Union[dict, str]: """Get LLM thread id for user or chatroom""" session = self.DBSession() try: # Check if it's a chatroom ID if wxid.endswith("@chatroom"): chatroom = session.query(Chatroom).filter_by(chatroom_id=wxid).first() if namespace: return chatroom.llm_thread_id.get(namespace, "") if chatroom else "" else: return chatroom.llm_thread_id if chatroom else {} else: # Regular user user = session.query(User).filter_by(wxid=wxid).first() if namespace: return user.llm_thread_id.get(namespace, "") if user else "" else: return user.llm_thread_id if user else {} finally: session.close() def save_llm_thread_id(self, wxid: str, data: str, namespace: str) -> bool: """Save LLM thread id for user or chatroom""" session = self.DBSession() try: if wxid.endswith("@chatroom"): chatroom = session.query(Chatroom).filter_by(chatroom_id=wxid).first() if not chatroom: chatroom = Chatroom( chatroom_id=wxid, llm_thread_id={} ) session.add(chatroom) # 创建新字典并更新 new_thread_ids = dict(chatroom.llm_thread_id or {}) new_thread_ids[namespace] = data chatroom.llm_thread_id = new_thread_ids else: user = session.query(User).filter_by(wxid=wxid).first() if not user: user = User( wxid=wxid, llm_thread_id={} ) session.add(user) # 创建新字典并更新 new_thread_ids = dict(user.llm_thread_id or {}) new_thread_ids[namespace] = data user.llm_thread_id = new_thread_ids session.commit() logger.info(f"数据库: 成功保存 {wxid} 的 llm thread id") return True except Exception as e: session.rollback() logger.error(f"数据库: 保存用户llm thread id失败, 错误: {e}") return False finally: session.close() def delete_all_llm_thread_id(self): """Clear llm thread id for everyone""" session = self.DBSession() try: session.query(User).update({User.llm_thread_id: {}}) session.query(Chatroom).update({Chatroom.llm_thread_id: {}}) session.commit() return True except Exception as e: session.rollback() logger.error(f"数据库: 清除所有用户llm thread id失败, 错误: {e}") return False finally: session.close() def get_signin_streak(self, wxid: str) -> int: """Thread-safe get user's signin streak""" return self._execute_in_queue(self._get_signin_streak, wxid) def _get_signin_streak(self, wxid: str) -> int: session = self.DBSession() try: user = session.query(User).filter_by(wxid=wxid).first() return user.signin_streak if user else 0 finally: session.close() def set_signin_streak(self, wxid: str, streak: int) -> bool: """Thread-safe set user's signin streak""" return self._execute_in_queue(self._set_signin_streak, wxid, streak) def _set_signin_streak(self, wxid: str, streak: int) -> bool: session = self.DBSession() try: result = session.execute( update(User) .where(User.wxid == wxid) .values(signin_streak=streak) ) if result.rowcount == 0: user = User(wxid=wxid, signin_streak=streak) session.add(user) logger.info(f"数据库: 用户{wxid}连续签到天数设置为{streak}") session.commit() return True except SQLAlchemyError as e: session.rollback() logger.error(f"数据库: 用户{wxid}连续签到天数设置失败, 错误: {e}") return False finally: session.close() # CHATROOM def get_chatroom_list(self) -> list: """Get list of all chatrooms""" session = self.DBSession() try: chatrooms = session.query(Chatroom).all() return [chatroom.chatroom_id for chatroom in chatrooms] finally: session.close() def get_chatroom_members(self, chatroom_id: str) -> set: """Get members of a chatroom""" session = self.DBSession() try: chatroom = session.query(Chatroom).filter_by(chatroom_id=chatroom_id).first() return set(chatroom.members) if chatroom else set() finally: session.close() def set_chatroom_members(self, chatroom_id: str, members: set) -> bool: """Set members of a chatroom""" session = self.DBSession() try: chatroom = session.query(Chatroom).filter_by(chatroom_id=chatroom_id).first() if not chatroom: chatroom = Chatroom(chatroom_id=chatroom_id) session.add(chatroom) chatroom.members = list(members) # Convert set to list for JSON storage logger.info(f"Database: Set chatroom {chatroom_id} members successfully") session.commit() return True except Exception as e: session.rollback() logger.error(f"Database: Set chatroom {chatroom_id} members failed, error: {e}") return False finally: session.close() def get_users_count(self): session = self.DBSession() try: return session.query(User).count() finally: session.close() def __del__(self): """确保关闭时清理资源""" if hasattr(self, 'executor'): self.executor.shutdown(wait=True) if hasattr(self, 'engine'): self.engine.dispose() ================================================ FILE: database/keyvalDB.py ================================================ import asyncio import logging import tomllib from datetime import datetime, timedelta from typing import Optional, Union, List from pydantic import validate_arguments from sqlalchemy import Column, String, Text, DateTime, delete, select from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_scoped_session from sqlalchemy.orm import declarative_base, sessionmaker from utils.singleton import Singleton DeclarativeBase = declarative_base() class KeyValue(DeclarativeBase): __tablename__ = 'key_value_store' key = Column(String(255), primary_key=True, unique=True, comment='键名') value = Column(Text, nullable=False, comment='存储值') expire_time = Column(DateTime, index=True, comment='过期时间') class KeyvalDB(metaclass=Singleton): _instance = None def __new__(cls): with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) db_url = main_config["XYBot"]["keyvalDB-url"] if cls._instance is None: cls._instance = super().__new__(cls) cls._instance.engine = create_async_engine( db_url, echo=False, future=True ) cls._async_session_factory = async_scoped_session( sessionmaker( cls._instance.engine, class_=AsyncSession, expire_on_commit=False ), scopefunc=asyncio.current_task ) return cls._instance async def initialize(self): """异步初始化数据库""" async with self.engine.begin() as conn: await conn.run_sync(DeclarativeBase.metadata.create_all) # 启动后台清理任务 asyncio.create_task(self._cleanup_expired()) @validate_arguments async def set( self, key: str, value: Union[str, dict, list], ex: Optional[Union[int, timedelta]] = None ) -> bool: """设置键值对,支持过期时间(秒或timedelta)""" async with self._async_session_factory() as session: try: expire_time = None if ex: # 统一处理时间类型转换 if isinstance(ex, int): expire_time = datetime.now() + timedelta(seconds=ex) else: expire_time = datetime.now() + ex kv = KeyValue( key=key, value=str(value), expire_time=expire_time ) await session.merge(kv) await session.commit() return True except Exception as e: logging.error(f"设置键值失败: {str(e)}") await session.rollback() return False async def get(self, key: str) -> Optional[str]: """获取键值,自动处理过期数据""" async with self._async_session_factory() as session: result = await session.get(KeyValue, key) if not result: return None if result.expire_time and result.expire_time < datetime.now(): await session.delete(result) await session.commit() return None return result.value async def delete(self, key: str) -> bool: """删除键值""" async with self._async_session_factory() as session: result = await session.execute(delete(KeyValue).where(KeyValue.key == key)) await session.commit() return result.rowcount > 0 async def exists(self, key: str) -> bool: """检查键是否存在""" async with self._async_session_factory() as session: result = await session.get(KeyValue, key) if result and result.expire_time and result.expire_time < datetime.now(): await session.delete(result) await session.commit() return False return result is not None async def ttl(self, key: str) -> int: """获取剩余生存时间(秒)""" async with self._async_session_factory() as session: result = await session.get(KeyValue, key) if not result or not result.expire_time: return -1 remaining = (result.expire_time - datetime.now()).total_seconds() # 明确返回类型处理 return int(remaining) if remaining > 0 else -2 async def expire(self, key: str, ex: Union[int, timedelta]) -> bool: """设置过期时间""" async with self._async_session_factory() as session: result = await session.get(KeyValue, key) if not result: return False expire_time = datetime.now() + (ex if isinstance(ex, timedelta) else timedelta(seconds=ex)) result.expire_time = expire_time await session.commit() return True async def keys(self, pattern: str = "*") -> List[str]: """查找匹配模式的键""" async with self._async_session_factory() as session: # 显式指定查询列类型 query = select(KeyValue.key).where(KeyValue.key.like(pattern.replace("*", "%"))) result = await session.execute(query) return [str(row[0]) for row in result.all()] # 确保返回字符串类型 async def _cleanup_expired(self, interval: int = 3600): """后台定时清理过期数据""" while True: async with self._async_session_factory() as session: await session.execute( delete(KeyValue).where(KeyValue.expire_time < datetime.now()) ) await session.commit() await asyncio.sleep(interval) async def close(self): """关闭数据库连接""" try: # 取消清理任务如果正在运行 for task in asyncio.all_tasks(): if task != asyncio.current_task() and '_cleanup_expired' in str(task): task.cancel() try: await task except asyncio.CancelledError: pass # 关闭连接 await self.engine.dispose() return True except asyncio.CancelledError: logging.warning("键值数据库关闭过程被取消,这可能是正常的关闭行为") return True except Exception as e: logging.error(f"关闭键值数据库连接时出错: {str(e)}") return False async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): await self.close() ================================================ FILE: database/messsagDB.py ================================================ import asyncio import logging import tomllib from datetime import datetime, timedelta from typing import Optional, List from pydantic import validate_arguments from sqlalchemy import Column, String, Integer, DateTime, Text, Boolean, delete from sqlalchemy import select from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_scoped_session from sqlalchemy.orm import declarative_base, sessionmaker from utils.singleton import Singleton # 使用新的声明式基类 DeclarativeBase = declarative_base() class Message(DeclarativeBase): __tablename__ = 'messages' id = Column(Integer, primary_key=True, autoincrement=True) msg_id = Column(Integer, index=True, comment='消息唯一ID(整型)') sender_wxid = Column(String(40), index=True, comment='消息发送人wxid') from_wxid = Column(String(40), index=True, comment='消息来源wxid') msg_type = Column(Integer, comment='消息类型(整型编码)') content = Column(Text, comment='消息内容') timestamp = Column(DateTime, default=datetime.now, index=True, comment='消息时间戳') is_group = Column(Boolean, default=False, comment='是否群消息') class MessageDB(metaclass=Singleton): _instance = None def __new__(cls): with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) db_url = main_config["XYBot"]["msgDB-url"] if cls._instance is None: cls._instance = super().__new__(cls) cls._instance.engine = create_async_engine( db_url, echo=False, future=True ) cls._async_session_factory = async_scoped_session( sessionmaker( cls._instance.engine, class_=AsyncSession, expire_on_commit=False ), scopefunc=asyncio.current_task ) return cls._instance async def initialize(self): """异步初始化数据库""" async with self.engine.begin() as conn: await conn.run_sync(DeclarativeBase.metadata.create_all) @validate_arguments(config=dict(arbitrary_types_allowed=True)) async def save_message(self, msg_id: int, sender_wxid: str, from_wxid: str, msg_type: int, content: str, is_group: bool = False) -> bool: """异步保存消息到数据库""" async with self._async_session_factory() as session: try: message = Message( msg_id=msg_id, sender_wxid=sender_wxid, from_wxid=from_wxid, msg_type=msg_type, content=content, is_group=is_group, timestamp=datetime.now() ) session.add(message) await session.commit() return True except Exception as e: logging.error(f"保存消息失败: {str(e)}") await session.rollback() return False async def get_messages(self, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, sender_wxid: Optional[str] = None, from_wxid: Optional[str] = None, msg_type: Optional[int] = None, is_group: Optional[bool] = None, limit: int = 100) -> List[Message]: """异步查询消息记录""" async with self._async_session_factory() as session: try: query = select(Message).order_by(Message.timestamp.desc()).limit(limit) if start_time: query = query.where(Message.timestamp >= start_time) if end_time: query = query.where(Message.timestamp <= end_time) if sender_wxid: query = query.where(Message.sender_wxid == sender_wxid) if from_wxid: query = query.where(Message.from_wxid == from_wxid) if msg_type is not None: query = query.where(Message.msg_type == msg_type) if is_group is not None: query = query.where(Message.is_group == is_group) result = await session.execute(query) return result.scalars().all() except Exception as e: logging.error(f"查询消息失败: {str(e)}") return [] async def close(self): """关闭数据库连接""" try: # 取消清理任务如果正在运行 for task in asyncio.all_tasks(): if task != asyncio.current_task() and 'cleanup_messages' in str(task): task.cancel() try: await task except asyncio.CancelledError: pass # 关闭连接 await self.engine.dispose() return True except asyncio.CancelledError: logging.warning("数据库关闭过程被取消,这可能是正常的关闭行为") return True except Exception as e: logging.error(f"关闭数据库连接时出错: {str(e)}") return False async def cleanup_messages(self): """每三天清理旧消息""" while True: async with self._async_session_factory() as session: try: # 计算三天前的时间 three_days_ago = datetime.now() - timedelta(days=3) # 删除三天前的消息 await session.execute( delete(Message).where(Message.timestamp < three_days_ago) ) await session.commit() except Exception as e: logging.error(f"清理消息失败: {str(e)}") await session.rollback() await asyncio.sleep(259200) # 每三天(259200秒)执行一次 async def __aenter__(self): # 启动清理消息的定时任务 asyncio.create_task(self.cleanup_messages()) return self async def __aexit__(self, exc_type, exc_val, exc_tb): await self.close() ================================================ FILE: docker-compose.yml ================================================ services: xybotv2: image: henryxiaoyang/xybotv2:latest container_name: XYBotV2 restart: on-failure:3 ports: - "9999:9999" # 映射gunicorn端口 volumes: - xybotv2:/app - redis_data:/var/lib/redis volumes: redis_data: name: redis_data xybotv2: name: xybotv2 ================================================ FILE: docs/.nojekyll ================================================ ================================================ FILE: docs/README.md ================================================ # 🤖 XYBot V2 XYBot V2 是一个功能丰富的微信机器人框架,支持多种互动功能和游戏玩法。 # 免责声明 - 这个项目免费开源,不存在收费。 - 本工具仅供学习和技术研究使用,不得用于任何商业或非法行为。 - 本工具的作者不对本工具的安全性、完整性、可靠性、有效性、正确性或适用性做任何明示或暗示的保证,也不对本工具的使用或滥用造成的任何直接或间接的损失、责任、索赔、要求或诉讼承担任何责任。 - 本工具的作者保留随时修改、更新、删除或终止本工具的权利,无需事先通知或承担任何义务。 - 本工具的使用者应遵守相关法律法规,尊重微信的版权和隐私,不得侵犯微信或其他第三方的合法权益,不得从事任何违法或不道德的行为。 - 本工具的使用者在下载、安装、运行或使用本工具时,即表示已阅读并同意本免责声明。如有异议,请立即停止使用本工具,并删除所有相关文件。 # 💬 微信交流群 # 🙏 赞助

开源不易,请作者喝杯奶茶吧🙏

微信收款码 微信收款码
# ✨ 主要功能 ## 🛠️ 基础功能 - 🤖 AI聊天 - 支持文字、图片、语音等多模态交互 - 📰 每日新闻 - 自动推送每日新闻 - 🎵 点歌系统 - 支持在线点歌 - 🌤️ 天气查询 - 查询全国各地天气 - 🎮 游戏功能 - 五子棋、战争雷霆玩家查询等 ## 💎 积分系统 - 📝 每日签到 - 支持连续签到奖励 - 🎲 抽奖系统 - 多种抽奖玩法 - 🧧 红包系统 - 群内发积分红包 - 💰 积分交易 - 用户间积分转账 - 📊 积分排行 - 查看积分排名 ## 👮 管理功能 - ⚙️ 插件管理 - 动态加载/卸载插件 - 👥 白名单管理 - 控制机器人使用权限 - 📊 积分管理 - 管理员可调整用户积分 - 🔄 签到重置 - 重置所有用户签到状态 # 🔌 插件系统 XYBot V2 采用插件化设计,所有功能都以插件形式实现。主要插件包括: - 👨‍💼 AdminPoint - 积分管理 - 🔄 AdminSignInReset - 签到重置 - 🛡️ AdminWhitelist - 白名单管理 - 🤖 Ai - AI聊天 - 📊 BotStatus - 机器人状态 - 📱 GetContact - 获取通讯录 - 🌤️ GetWeather - 天气查询 - 🎮 Gomoku - 五子棋游戏 - 🌅 GoodMorning - 早安问候 - 📈 Leaderboard - 积分排行 - 🎲 LuckyDraw - 幸运抽奖 - 📋 Menu - 菜单系统 - 🎵 Music - 点歌系统 - 📰 News - 新闻推送 - 💱 PointTrade - 积分交易 - 💰 QueryPoint - 积分查询 - 🎯 RandomMember - 随机群成员 - 🖼️ RandomPicture - 随机图片 - 🧧 RedPacket - 红包系统 - ✍️ SignIn - 每日签到 - ✈️ Warthunder - 战争雷霆查询 # 💻 代码提交 提交代码时请使用 `feat: something` 作为说明,支持的标识如下: - `feat` 新功能(feature) - `fix` 修复bug - `docs` 文档(documentation) - `style` 格式(不影响代码运行的变动) - `ref` 重构(即不是新增功能,也不是修改bug的代码变动) - `perf` 性能优化(performance) - `test` 增加测试 - `chore` 构建过程或辅助工具的变动 - `revert` 撤销 ================================================ FILE: docs/WechatAPIClient/_modules/WechatAPI/Client/base.html ================================================ WechatAPI.Client.base - XYBotV2 Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
Back to top

WechatAPI.Client.base 源代码

from dataclasses import dataclass

from WechatAPI.errors import *


[文档] @dataclass class Proxy: """代理(无效果,别用!) Args: ip (str): 代理服务器IP地址 port (int): 代理服务器端口 username (str, optional): 代理认证用户名. 默认为空字符串 password (str, optional): 代理认证密码. 默认为空字符串 """ ip: str port: int username: str = "" password: str = ""
[文档] @dataclass class Section: """数据段配置类 Args: data_len (int): 数据长度 start_pos (int): 起始位置 """ data_len: int start_pos: int
[文档] class WechatAPIClientBase: """微信API客户端基类 Args: ip (str): 服务器IP地址 port (int): 服务器端口 Attributes: wxid (str): 微信ID nickname (str): 昵称 alias (str): 别名 phone (str): 手机号 ignore_protect (bool): 是否忽略保护机制 """ def __init__(self, ip: str, port: int): self.ip = ip self.port = port self.wxid = "" self.nickname = "" self.alias = "" self.phone = "" self.ignore_protect = False # 调用所有 Mixin 的初始化方法 super().__init__()
[文档] @staticmethod def error_handler(json_resp): """处理API响应中的错误码 Args: json_resp (dict): API响应的JSON数据 Raises: ValueError: 参数错误时抛出 MarshallingError: 序列化错误时抛出 UnmarshallingError: 反序列化错误时抛出 MMTLSError: MMTLS初始化错误时抛出 PacketError: 数据包长度错误时抛出 UserLoggedOut: 用户已退出登录时抛出 ParsePacketError: 解析数据包错误时抛出 DatabaseError: 数据库错误时抛出 Exception: 其他类型错误时抛出 """ code = json_resp.get("Code") if code == -1: # 参数错误 raise ValueError(json_resp.get("Message")) elif code == -2: # 其他错误 raise Exception(json_resp.get("Message")) elif code == -3: # 序列化错误 raise MarshallingError(json_resp.get("Message")) elif code == -4: # 反序列化错误 raise UnmarshallingError(json_resp.get("Message")) elif code == -5: # MMTLS初始化错误 raise MMTLSError(json_resp.get("Message")) elif code == -6: # 收到的数据包长度错误 raise PacketError(json_resp.get("Message")) elif code == -7: # 已退出登录 raise UserLoggedOut("Already logged out") elif code == -8: # 链接过期 raise Exception(json_resp.get("Message")) elif code == -9: # 解析数据包错误 raise ParsePacketError(json_resp.get("Message")) elif code == -10: # 数据库错误 raise DatabaseError(json_resp.get("Message")) elif code == -11: # 登陆异常 raise UserLoggedOut(json_resp.get("Message")) elif code == -12: # 操作过于频繁 raise Exception(json_resp.get("Message")) elif code == -13: # 上传失败 raise Exception(json_resp.get("Message"))
================================================ FILE: docs/WechatAPIClient/_modules/WechatAPI/Client/chatroom.html ================================================ WechatAPI.Client.chatroom - XYBotV2 Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
Back to top

WechatAPI.Client.chatroom 源代码

from typing import Union, Any

import aiohttp

from .base import *
from .protect import protector
from ..errors import *


[文档] class ChatroomMixin(WechatAPIClientBase):
[文档] async def add_chatroom_member(self, chatroom: str, wxid: str) -> bool: """添加群成员(群聊最多40人) Args: chatroom: 群聊wxid wxid: 要添加的wxid Returns: bool: 成功返回True, 失败False或者报错 """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Chatroom": chatroom, "InviteWxids": wxid} response = await session.post(f'http://{self.ip}:{self.port}/AddChatroomMember', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp)
[文档] async def get_chatroom_announce(self, chatroom: str) -> dict: """获取群聊公告 Args: chatroom: 群聊id Returns: dict: 群聊信息字典 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Chatroom": chatroom} response = await session.post(f'http://{self.ip}:{self.port}/GetChatroomInfo', json=json_param) json_resp = await response.json() if json_resp.get("Success"): data = dict(json_resp.get("Data")) data.pop("BaseResponse") return data else: self.error_handler(json_resp)
[文档] async def get_chatroom_info(self, chatroom: str) -> dict: """获取群聊信息 Args: chatroom: 群聊id Returns: dict: 群聊信息字典 """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Chatroom": chatroom} response = await session.post(f'http://{self.ip}:{self.port}/GetChatroomInfoNoAnnounce', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("ContactList")[0] else: self.error_handler(json_resp)
[文档] async def get_chatroom_member_list(self, chatroom: str) -> list[dict]: """获取群聊成员列表 Args: chatroom: 群聊id Returns: list[dict]: 群聊成员列表 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Chatroom": chatroom} response = await session.post(f'http://{self.ip}:{self.port}/GetChatroomMemberDetail', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("NewChatroomData").get("ChatRoomMember") else: self.error_handler(json_resp)
[文档] async def get_chatroom_qrcode(self, chatroom: str) -> dict[str, Any]: """获取群聊二维码 Args: chatroom: 群聊id Returns: dict: {"base64": 二维码的base64, "description": 二维码描述} """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(86400): raise BanProtection("获取二维码需要在登录后24小时才可使用") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Chatroom": chatroom} response = await session.post(f'http://{self.ip}:{self.port}/GetChatroomQRCode', json=json_param) json_resp = await response.json() if json_resp.get("Success"): data = json_resp.get("Data") return {"base64": data.get("qrcode").get("buffer"), "description": data.get("revokeQrcodeWording")} else: self.error_handler(json_resp)
[文档] async def invite_chatroom_member(self, wxid: Union[str, list], chatroom: str) -> bool: """邀请群聊成员(群聊大于40人) Args: wxid: 要邀请的用户wxid或wxid列表 chatroom: 群聊id Returns: bool: 成功返回True, 失败False或者报错 """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") if isinstance(wxid, list): wxid = ",".join(wxid) async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Chatroom": chatroom, "InviteWxids": wxid} response = await session.post(f'http://{self.ip}:{self.port}/InviteChatroomMember', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp)
================================================ FILE: docs/WechatAPIClient/_modules/WechatAPI/Client/friend.html ================================================ WechatAPI.Client.friend - XYBotV2 Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
Back to top

WechatAPI.Client.friend 源代码

from typing import Union

import aiohttp

from .base import *
from .protect import protector
from ..errors import *


[文档] class FriendMixin(WechatAPIClientBase):
[文档] async def accept_friend(self, scene: int, v1: str, v2: str) -> bool: """接受好友请求 主动添加好友单天上限如下所示:1小时内上限为 5个,超过上限时,无法发出好友请求,也收不到好友请求。 - 新账号:5/天 - 注册超过7天:10个/天 - 注册满3个月&&近期登录过该电脑:15/天 - 注册满6个月&&近期经常登录过该电脑:20/天 - 注册满6个月&&近期频繁登陆过该电脑:30/天 - 注册1年以上&&一直登录:50/天 - 上一次通过好友到下一次通过间隔20-40s - 收到加人申请,到通过好友申请(每天最多通过300个好友申请),间隔30s+(随机时间) Args: scene: 来源 在消息的xml获取 v1: v1key v2: v2key Returns: bool: 操作是否成功 """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Scene": scene, "V1": v1, "V2": v2} response = await session.post(f'http://{self.ip}:{self.port}/AcceptFriend', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp)
[文档] async def get_contact(self, wxid: Union[str, list[str]]) -> Union[dict, list[dict]]: """获取联系人信息 Args: wxid: 联系人wxid, 可以是多个wxid在list里,也可查询chatroom Returns: Union[dict, list[dict]]: 单个联系人返回dict,多个联系人返回list[dict] """ if not self.wxid: raise UserLoggedOut("请先登录") if isinstance(wxid, list): wxid = ",".join(wxid) async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "RequestWxids": wxid} response = await session.post(f'http://{self.ip}:{self.port}/GetContact', json=json_param) json_resp = await response.json() if json_resp.get("Success"): contact_list = json_resp.get("Data").get("ContactList") if len(contact_list) == 1: return contact_list[0] else: return contact_list else: self.error_handler(json_resp)
[文档] async def get_contract_detail(self, wxid: Union[str, list[str]], chatroom: str = "") -> list: """获取联系人详情 Args: wxid: 联系人wxid chatroom: 群聊wxid Returns: list: 联系人详情列表 """ if not self.wxid: raise UserLoggedOut("请先登录") if isinstance(wxid, list): if len(wxid) > 20: raise ValueError("一次最多查询20个联系人") wxid = ",".join(wxid) async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "RequestWxids": wxid, "Chatroom": chatroom} response = await session.post(f'http://{self.ip}:{self.port}/GetContractDetail', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("ContactList") else: self.error_handler(json_resp)
[文档] async def get_contract_list(self, wx_seq: int = 0, chatroom_seq: int = 0) -> dict: """获取联系人列表 Args: wx_seq: 联系人序列 chatroom_seq: 群聊序列 Returns: dict: 联系人列表数据 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "CurrentWxcontactSeq": wx_seq, "CurrentChatroomContactSeq": chatroom_seq} response = await session.post(f'http://{self.ip}:{self.port}/GetContractList', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data") else: self.error_handler(json_resp)
[文档] async def get_nickname(self, wxid: Union[str, list[str]]) -> Union[str, list[str]]: """获取用户昵称 Args: wxid: 用户wxid,可以是单个wxid或最多20个wxid的列表 Returns: Union[str, list[str]]: 如果输入单个wxid返回str,如果输入wxid列表则返回对应的昵称列表 """ data = await self.get_contract_detail(wxid) if isinstance(wxid, str): try: return data[0].get("NickName").get("string") except: return "" else: result = [] for contact in data: try: result.append(contact.get("NickName").get("string")) except: result.append("") return result
================================================ FILE: docs/WechatAPIClient/_modules/WechatAPI/Client/hongbao.html ================================================ WechatAPI.Client.hongbao - XYBotV2 Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
Back to top

WechatAPI.Client.hongbao 源代码

import aiohttp

from .base import *
from ..errors import *


[文档] class HongBaoMixin(WechatAPIClientBase):
[文档] async def get_hongbao_detail(self, xml: str, encrypt_key: str, encrypt_userinfo: str) -> dict: """获取红包详情 Args: xml: 红包 XML 数据 encrypt_key: 加密密钥 encrypt_userinfo: 加密的用户信息 Returns: dict: 红包详情数据 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Xml": xml, "EncryptKey": encrypt_key, "EncryptUserinfo": encrypt_userinfo} response = await session.post(f'http://{self.ip}:{self.port}/GetHongBaoDetail', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data") else: self.error_handler(json_resp)
================================================ FILE: docs/WechatAPIClient/_modules/WechatAPI/Client/login.html ================================================ WechatAPI.Client.login - XYBotV2 Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
Back to top

WechatAPI.Client.login 源代码

import hashlib
import string
from random import choice
from typing import Union

import aiohttp
import qrcode

from .base import *
from .protect import protector
from ..errors import *


[文档] class LoginMixin(WechatAPIClientBase):
[文档] async def is_running(self) -> bool: """检查WechatAPI是否在运行。 Returns: bool: 如果WechatAPI正在运行返回True,否则返回False。 """ try: async with aiohttp.ClientSession() as session: response = await session.get(f'http://{self.ip}:{self.port}/IsRunning') return await response.text() == 'OK' except aiohttp.client_exceptions.ClientConnectorError: return False
[文档] async def get_qr_code(self, device_name: str, device_id: str = "", proxy: Proxy = None, print_qr: bool = False) -> ( str, str): """获取登录二维码。 Args: device_name (str): 设备名称 device_id (str, optional): 设备ID. Defaults to "". proxy (Proxy, optional): 代理信息. Defaults to None. print_qr (bool, optional): 是否在控制台打印二维码. Defaults to False. Returns: tuple[str, str]: 返回登录二维码的UUID和URL Raises: 根据error_handler处理错误 """ async with aiohttp.ClientSession() as session: json_param = {'DeviceName': device_name, 'DeviceID': device_id} if proxy: json_param['ProxyInfo'] = {'ProxyIp': f'{proxy.ip}:{proxy.port}', 'ProxyPassword': proxy.password, 'ProxyUser': proxy.username} response = await session.post(f'http://{self.ip}:{self.port}/GetQRCode', json=json_param) json_resp = await response.json() if json_resp.get("Success"): if print_qr: qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=4, ) qr.add_data(f'http://weixin.qq.com/x/{json_resp.get("Data").get("Uuid")}') qr.make(fit=True) qr.print_ascii() return json_resp.get("Data").get("Uuid"), json_resp.get("Data").get("QRCodeURL") else: self.error_handler(json_resp)
[文档] async def check_login_uuid(self, uuid: str, device_id: str = "") -> tuple[bool, Union[dict, int]]: """检查登录的UUID状态。 Args: uuid (str): 登录的UUID device_id (str, optional): 设备ID. Defaults to "". Returns: tuple[bool, Union[dict, int]]: 如果登录成功返回(True, 用户信息),否则返回(False, 过期时间) Raises: 根据error_handler处理错误 """ async with aiohttp.ClientSession() as session: json_param = {"Uuid": uuid} response = await session.post(f'http://{self.ip}:{self.port}/CheckUuid', json=json_param) json_resp = await response.json() if json_resp.get("Success"): if json_resp.get("Data").get("acctSectResp", ""): self.wxid = json_resp.get("Data").get("acctSectResp").get("userName") self.nickname = json_resp.get("Data").get("acctSectResp").get("nickName") protector.update_login_status(device_id=device_id) return True, json_resp.get("Data") else: return False, json_resp.get("Data").get("expiredTime") else: self.error_handler(json_resp)
[文档] async def log_out(self) -> bool: """登出当前账号。 Returns: bool: 登出成功返回True,否则返回False Raises: UserLoggedOut: 如果未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid} response = await session.post(f'http://{self.ip}:{self.port}/Logout', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True elif json_resp.get("Success"): return False else: self.error_handler(json_resp)
[文档] async def awaken_login(self, wxid: str = "") -> str: """唤醒登录。 Args: wxid (str, optional): 要唤醒的微信ID. Defaults to "". Returns: str: 返回新的登录UUID Raises: Exception: 如果未提供wxid且未登录 LoginError: 如果无法获取UUID 根据error_handler处理错误 """ if not wxid and not self.wxid: raise Exception("Please login using QRCode first") if not wxid and self.wxid: wxid = self.wxid async with aiohttp.ClientSession() as session: json_param = {"Wxid": wxid} response = await session.post(f'http://{self.ip}:{self.port}/AwakenLogin', json=json_param) json_resp = await response.json() if json_resp.get("Success") and json_resp.get("Data").get("QrCodeResponse").get("Uuid"): return json_resp.get("Data").get("QrCodeResponse").get("Uuid") elif not json_resp.get("Data").get("QrCodeResponse").get("Uuid"): raise LoginError("Please login using QRCode first") else: self.error_handler(json_resp)
[文档] async def get_cached_info(self, wxid: str = None) -> dict: """获取登录缓存信息。 Args: wxid (str, optional): 要查询的微信ID. Defaults to None. Returns: dict: 返回缓存信息,如果未提供wxid且未登录返回空字典 """ if not wxid: wxid = self.wxid if not wxid: return {} async with aiohttp.ClientSession() as session: json_param = {"Wxid": wxid} response = await session.post(f'http://{self.ip}:{self.port}/GetCachedInfo', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data") else: return {}
[文档] async def heartbeat(self) -> bool: """发送心跳包。 Returns: bool: 成功返回True,否则返回False Raises: UserLoggedOut: 如果未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid} response = await session.post(f'http://{self.ip}:{self.port}/Heartbeat', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp)
[文档] async def start_auto_heartbeat(self) -> bool: """开始自动心跳。 Returns: bool: 成功返回True,否则返回False Raises: UserLoggedOut: 如果未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid} response = await session.post(f'http://{self.ip}:{self.port}/AutoHeartbeatStart', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp)
[文档] async def stop_auto_heartbeat(self) -> bool: """停止自动心跳。 Returns: bool: 成功返回True,否则返回False Raises: UserLoggedOut: 如果未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid} response = await session.post(f'http://{self.ip}:{self.port}/AutoHeartbeatStop', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp)
[文档] async def get_auto_heartbeat_status(self) -> bool: """获取自动心跳状态。 Returns: bool: 如果正在运行返回True,否则返回False Raises: UserLoggedOut: 如果未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid} response = await session.post(f'http://{self.ip}:{self.port}/AutoHeartbeatStatus', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("Running") else: return self.error_handler(json_resp)
[文档] @staticmethod def create_device_name() -> str: """生成一个随机的设备名。 Returns: str: 返回生成的设备名 """ first_names = [ "Oliver", "Emma", "Liam", "Ava", "Noah", "Sophia", "Elijah", "Isabella", "James", "Mia", "William", "Amelia", "Benjamin", "Harper", "Lucas", "Evelyn", "Henry", "Abigail", "Alexander", "Ella", "Jackson", "Scarlett", "Sebastian", "Grace", "Aiden", "Chloe", "Matthew", "Zoey", "Samuel", "Lily", "David", "Aria", "Joseph", "Riley", "Carter", "Nora", "Owen", "Luna", "Daniel", "Sofia", "Gabriel", "Ellie", "Matthew", "Avery", "Isaac", "Mila", "Leo", "Julian", "Layla" ] last_names = [ "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin", "Lee", "Perez", "Thompson", "White", "Harris", "Sanchez", "Clark", "Ramirez", "Lewis", "Robinson", "Walker", "Young", "Allen", "King", "Wright", "Scott", "Torres", "Nguyen", "Hill", "Flores", "Green", "Adams", "Nelson", "Baker", "Hall", "Rivera", "Campbell", "Mitchell", "Carter", "Roberts", "Gomez", "Phillips", "Evans" ] return choice(first_names) + " " + choice(last_names) + "'s Pad"
[文档] @staticmethod def create_device_id(s: str = "") -> str: """生成设备ID。 Args: s (str, optional): 用于生成ID的字符串. Defaults to "". Returns: str: 返回生成的设备ID """ if s == "" or s == "string": s = ''.join(choice(string.ascii_letters) for _ in range(15)) md5_hash = hashlib.md5(s.encode()).hexdigest() return "49" + md5_hash[2:]
================================================ FILE: docs/WechatAPIClient/_modules/WechatAPI/Client/message.html ================================================ WechatAPI.Client.message - XYBotV2 Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
Back to top

WechatAPI.Client.message 源代码

import asyncio
import base64
import os
from asyncio import Future
from asyncio import Queue, sleep
from io import BytesIO
from pathlib import Path
from typing import Union

import aiohttp
import pysilk
from loguru import logger
from pydub import AudioSegment
from pymediainfo import MediaInfo

from .base import *
from .protect import protector
from ..errors import *


[文档] class MessageMixin(WechatAPIClientBase): def __init__(self, ip: str, port: int): # 初始化消息队列 super().__init__(ip, port) self._message_queue = Queue() self._is_processing = False
[文档] async def _process_message_queue(self): """ 处理消息队列的异步方法 """ if self._is_processing: return self._is_processing = True while True: if self._message_queue.empty(): self._is_processing = False break func, args, kwargs, future = await self._message_queue.get() try: result = await func(*args, **kwargs) future.set_result(result) except Exception as e: future.set_exception(e) finally: self._message_queue.task_done() await sleep(1) # 消息发送间隔1秒
[文档] async def _queue_message(self, func, *args, **kwargs): """ 将消息添加到队列 """ future = Future() await self._message_queue.put((func, args, kwargs, future)) if not self._is_processing: asyncio.create_task(self._process_message_queue()) return await future
[文档] async def revoke_message(self, wxid: str, client_msg_id: int, create_time: int, new_msg_id: int) -> bool: """撤回消息。 Args: wxid (str): 接收人wxid client_msg_id (int): 发送消息的返回值 create_time (int): 发送消息的返回值 new_msg_id (int): 发送消息的返回值 Returns: bool: 成功返回True,失败返回False Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "ClientMsgId": client_msg_id, "CreateTime": create_time, "NewMsgId": new_msg_id} response = await session.post(f'http://{self.ip}:{self.port}/RevokeMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("消息撤回成功: 对方wxid:{} ClientMsgId:{} CreateTime:{} NewMsgId:{}", wxid, client_msg_id, new_msg_id) return True else: self.error_handler(json_resp)
[文档] async def send_text_message(self, wxid: str, content: str, at: Union[list, str] = "") -> tuple[int, int, int]: """发送文本消息。 Args: wxid (str): 接收人wxid content (str): 消息内容 at (list, str, optional): 要@的用户 Returns: tuple[int, int, int]: 返回(ClientMsgid, CreateTime, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ return await self._queue_message(self._send_text_message, wxid, content, at)
[文档] async def _send_text_message(self, wxid: str, content: str, at: list[str] = None) -> tuple[int, int, int]: """ 实际发送文本消息的方法 """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") if isinstance(at, str): at_str = at elif isinstance(at, list): if at is None: at = [] at_str = ",".join(at) else: raise ValueError("Argument 'at' should be str or list") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Content": content, "Type": 1, "At": at_str} response = await session.post(f'http://{self.ip}:{self.port}/SendTextMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("发送文字消息: 对方wxid:{} at:{} 内容:{}", wxid, at, content) data = json_resp.get("Data") return data.get("List")[0].get("ClientMsgid"), data.get("List")[0].get("Createtime"), data.get("List")[ 0].get("NewMsgId") else: self.error_handler(json_resp)
[文档] async def send_image_message(self, wxid: str, image: Union[str, bytes, os.PathLike]) -> tuple[int, int, int]: """发送图片消息。 Args: wxid (str): 接收人wxid image (str, byte, os.PathLike): 图片,支持base64字符串,图片byte,图片路径 Returns: tuple[int, int, int]: 返回(ClientImgId, CreateTime, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 ValueError: image_path和image_base64都为空或都不为空时 根据error_handler处理错误 """ return await self._queue_message(self._send_image_message, wxid, image)
async def _send_image_message(self, wxid: str, image: Union[str, bytes, os.PathLike]) -> tuple[ int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") if isinstance(image, str): pass elif isinstance(image, bytes): image = base64.b64encode(image).decode() elif isinstance(image, os.PathLike): with open(image, 'rb') as f: image = base64.b64encode(f.read()).decode() else: raise ValueError("Argument 'image' can only be str, bytes, or os.PathLike") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Base64": image} response = await session.post(f'http://{self.ip}:{self.port}/SendImageMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): json_param.pop('Base64') logger.info("发送图片消息: 对方wxid:{} 图片base64略", wxid) data = json_resp.get("Data") return data.get("ClientImgId").get("string"), data.get("CreateTime"), data.get("Newmsgid") else: self.error_handler(json_resp)
[文档] async def send_video_message(self, wxid: str, video: Union[str, bytes, os.PathLike], image: [str, bytes, os.PathLike] = None): """发送视频消息。不推荐使用,上传速度很慢300KB/s。如要使用,可压缩视频,或者发送链接卡片而不是视频。 Args: wxid (str): 接收人wxid video (str, bytes, os.PathLike): 视频 接受base64字符串,字节,文件路径 image (str, bytes, os.PathLike): 视频封面图片 接受base64字符串,字节,文件路径 Returns: tuple[int, int]: 返回(ClientMsgid, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 ValueError: 视频或图片参数都为空或都不为空时 根据error_handler处理错误 """ if not image: image = Path(os.path.join(Path(__file__).resolve().parent, "fallback.png")) # get video base64 and duration if isinstance(video, str): vid_base64 = video video = base64.b64decode(video) file_len = len(video) media_info = MediaInfo.parse(BytesIO(video)) elif isinstance(video, bytes): vid_base64 = base64.b64encode(video).decode() file_len = len(video) media_info = MediaInfo.parse(BytesIO(video)) elif isinstance(video, os.PathLike): with open(video, "rb") as f: file_len = len(f.read()) vid_base64 = base64.b64encode(f.read()).decode() media_info = MediaInfo.parse(video) else: raise ValueError("video should be str, bytes, or path") duration = media_info.tracks[0].duration # get image base64 if isinstance(image, str): image_base64 = image elif isinstance(image, bytes): image_base64 = base64.b64encode(image).decode() elif isinstance(image, os.PathLike): with open(image, "rb") as f: image_base64 = base64.b64encode(f.read()).decode() else: raise ValueError("image should be str, bytes, or path") # 打印预估时间,300KB/s predict_time = int(file_len / 1024 / 300) logger.info("开始发送视频: 对方wxid:{} 视频base64略 图片base64略 预计耗时:{}秒", wxid, predict_time) async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Base64": vid_base64, "ImageBase64": image_base64, "PlayLength": duration} async with session.post(f'http://{self.ip}:{self.port}/SendVideoMsg', json=json_param) as resp: json_resp = await resp.json() if json_resp.get("Success"): json_param.pop('Base64') json_param.pop('ImageBase64') logger.info("发送视频成功: 对方wxid:{} 时长:{} 视频base64略 图片base64略", wxid, duration) data = json_resp.get("Data") return data.get("clientMsgId"), data.get("newMsgId") else: self.error_handler(json_resp)
[文档] async def send_voice_message(self, wxid: str, voice: Union[str, bytes, os.PathLike], format: str = "amr") -> \ tuple[int, int, int]: """发送语音消息。 Args: wxid (str): 接收人wxid voice (str, bytes, os.PathLike): 语音 接受base64字符串,字节,文件路径 format (str, optional): 语音格式,支持amr/wav/mp3. Defaults to "amr". Returns: tuple[int, int, int]: 返回(ClientMsgid, CreateTime, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 ValueError: voice_path和voice_base64都为空或都不为空时,或format不支持时 根据error_handler处理错误 """ return await self._queue_message(self._send_voice_message, wxid, voice, format)
async def _send_voice_message(self, wxid: str, voice: Union[str, bytes, os.PathLike], format: str = "amr") -> \ tuple[int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") elif format not in ["amr", "wav", "mp3"]: raise ValueError("format must be one of amr, wav, mp3") # read voice to byte if isinstance(voice, str): voice_byte = base64.b64decode(voice) elif isinstance(voice, bytes): voice_byte = voice elif isinstance(voice, os.PathLike): with open(voice, "rb") as f: voice_byte = f.read() else: raise ValueError("voice should be str, bytes, or path") # get voice duration and b64 if format.lower() == "amr": audio = AudioSegment.from_file(BytesIO(voice_byte), format="amr") voice_base64 = base64.b64encode(voice_byte).decode() elif format.lower() == "wav": audio = AudioSegment.from_file(BytesIO(voice_byte), format="wav").set_channels(1) audio = audio.set_frame_rate(self._get_closest_frame_rate(audio.frame_rate)) voice_base64 = base64.b64encode( await pysilk.async_encode(audio.raw_data, sample_rate=audio.frame_rate)).decode() elif format.lower() == "mp3": audio = AudioSegment.from_file(BytesIO(voice_byte), format="mp3").set_channels(1) audio = audio.set_frame_rate(self._get_closest_frame_rate(audio.frame_rate)) voice_base64 = base64.b64encode( await pysilk.async_encode(audio.raw_data, sample_rate=audio.frame_rate)).decode() else: raise ValueError("format must be one of amr, wav, mp3") duration = len(audio) format_dict = {"amr": 0, "wav": 4, "mp3": 4} async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Base64": voice_base64, "VoiceTime": duration, "Type": format_dict[format]} response = await session.post(f'http://{self.ip}:{self.port}/SendVoiceMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): json_param.pop('Base64') logger.info("发送语音消息: 对方wxid:{} 时长:{} 格式:{} 音频base64略", wxid, duration, format) data = json_resp.get("Data") return int(data.get("ClientMsgId")), data.get("CreateTime"), data.get("NewMsgId") else: self.error_handler(json_resp) @staticmethod def _get_closest_frame_rate(frame_rate: int) -> int: supported = [8000, 12000, 16000, 24000] closest_rate = None smallest_diff = float('inf') for num in supported: diff = abs(frame_rate - num) if diff < smallest_diff: smallest_diff = diff closest_rate = num return closest_rate async def _send_link_message(self, wxid: str, url: str, title: str = "", description: str = "", thumb_url: str = "") -> tuple[int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Url": url, "Title": title, "Desc": description, "ThumbUrl": thumb_url} response = await session.post(f'http://{self.ip}:{self.port}/SendShareLink', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("发送链接消息: 对方wxid:{} 链接:{} 标题:{} 描述:{} 缩略图链接:{}", wxid, url, title, description, thumb_url) data = json_resp.get("Data") return data.get("clientMsgId"), data.get("createTime"), data.get("newMsgId") else: self.error_handler(json_resp)
[文档] async def send_emoji_message(self, wxid: str, md5: str, total_length: int) -> list[dict]: """发送表情消息。 Args: wxid (str): 接收人wxid md5 (str): 表情md5值 total_length (int): 表情总长度 Returns: list[dict]: 返回表情项列表(list of emojiItem) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ return await self._queue_message(self._send_emoji_message, wxid, md5, total_length)
async def _send_emoji_message(self, wxid: str, md5: str, total_length: int) -> tuple[int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Md5": md5, "TotalLen": total_length} response = await session.post(f'http://{self.ip}:{self.port}/SendEmojiMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("发送表情消息: 对方wxid:{} md5:{} 总长度:{}", wxid, md5, total_length) return json_resp.get("Data").get("emojiItem") else: self.error_handler(json_resp)
[文档] async def send_card_message(self, wxid: str, card_wxid: str, card_nickname: str, card_alias: str = "") -> tuple[ int, int, int]: """发送名片消息。 Args: wxid (str): 接收人wxid card_wxid (str): 名片用户的wxid card_nickname (str): 名片用户的昵称 card_alias (str, optional): 名片用户的备注. Defaults to "". Returns: tuple[int, int, int]: 返回(ClientMsgid, CreateTime, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ return await self._queue_message(self._send_card_message, wxid, card_wxid, card_nickname, card_alias)
async def _send_card_message(self, wxid: str, card_wxid: str, card_nickname: str, card_alias: str = "") -> tuple[ int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "CardWxid": card_wxid, "CardAlias": card_alias, "CardNickname": card_nickname} response = await session.post(f'http://{self.ip}:{self.port}/SendCardMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("发送名片消息: 对方wxid:{} 名片wxid:{} 名片备注:{} 名片昵称:{}", wxid, card_wxid, card_alias, card_nickname) data = json_resp.get("Data") return data.get("List")[0].get("ClientMsgid"), data.get("List")[0].get("Createtime"), data.get("List")[ 0].get("NewMsgId") else: self.error_handler(json_resp)
[文档] async def send_app_message(self, wxid: str, xml: str, type: int) -> tuple[str, int, int]: """发送应用消息。 Args: wxid (str): 接收人wxid xml (str): 应用消息的xml内容 type (int): 应用消息类型 Returns: tuple[str, int, int]: 返回(ClientMsgid, CreateTime, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ return await self._queue_message(self._send_app_message, wxid, xml, type)
async def _send_app_message(self, wxid: str, xml: str, type: int) -> tuple[int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Xml": xml, "Type": type} response = await session.post(f'http://{self.ip}:{self.port}/SendAppMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): json_param["Xml"] = json_param["Xml"].replace("\n", "") logger.info("发送app消息: 对方wxid:{} 类型:{} xml:{}", wxid, type, json_param["Xml"]) return json_resp.get("Data").get("clientMsgId"), json_resp.get("Data").get( "createTime"), json_resp.get("Data").get("newMsgId") else: self.error_handler(json_resp)
[文档] async def send_cdn_file_msg(self, wxid: str, xml: str) -> tuple[str, int, int]: """转发文件消息。 Args: wxid (str): 接收人wxid xml (str): 要转发的文件消息xml内容 Returns: tuple[str, int, int]: 返回(ClientMsgid, CreateTime, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ return await self._queue_message(self._send_cdn_file_msg, wxid, xml)
async def _send_cdn_file_msg(self, wxid: str, xml: str) -> tuple[int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Content": xml} response = await session.post(f'http://{self.ip}:{self.port}/SendCDNFileMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("转发文件消息: 对方wxid:{} xml:{}", wxid, xml) data = json_resp.get("Data") return data.get("clientMsgId"), data.get("createTime"), data.get("newMsgId") else: self.error_handler(json_resp)
[文档] async def send_cdn_img_msg(self, wxid: str, xml: str) -> tuple[str, int, int]: """转发图片消息。 Args: wxid (str): 接收人wxid xml (str): 要转发的图片消息xml内容 Returns: tuple[str, int, int]: 返回(ClientImgId, CreateTime, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ return await self._queue_message(self._send_cdn_img_msg, wxid, xml)
async def _send_cdn_img_msg(self, wxid: str, xml: str) -> tuple[int, int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Content": xml} response = await session.post(f'http://{self.ip}:{self.port}/SendCDNImgMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("转发图片消息: 对方wxid:{} xml:{}", wxid, xml) data = json_resp.get("Data") return data.get("ClientImgId").get("string"), data.get("CreateTime"), data.get("Newmsgid") else: self.error_handler(json_resp)
[文档] async def send_cdn_video_msg(self, wxid: str, xml: str) -> tuple[str, int]: """转发视频消息。 Args: wxid (str): 接收人wxid xml (str): 要转发的视频消息xml内容 Returns: tuple[str, int]: 返回(ClientMsgid, NewMsgId) Raises: UserLoggedOut: 未登录时调用 BanProtection: 登录新设备后4小时内操作 根据error_handler处理错误 """ return await self._queue_message(self._send_cdn_video_msg, wxid, xml)
async def _send_cdn_video_msg(self, wxid: str, xml: str) -> tuple[int, int]: if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Content": xml} response = await session.post(f'http://{self.ip}:{self.port}/SendCDNVideoMsg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): logger.info("转发视频消息: 对方wxid:{} xml:{}", wxid, xml) data = json_resp.get("Data") return data.get("clientMsgId"), data.get("newMsgId") else: self.error_handler(json_resp)
[文档] async def sync_message(self) -> dict: """同步消息。 Returns: dict: 返回同步到的消息数据 Raises: UserLoggedOut: 未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: json_param = {"Wxid": self.wxid, "Scene": 0, "Synckey": ""} response = await session.post(f'http://{self.ip}:{self.port}/Sync', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data") else: self.error_handler(json_resp)
================================================ FILE: docs/WechatAPIClient/_modules/WechatAPI/Client/protect.html ================================================ WechatAPI.Client.protect - XYBotV2 Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
Back to top

WechatAPI.Client.protect 源代码

import json
import os
from datetime import datetime


[文档] class Singleton(type): """单例模式的元类。 用于确保一个类只有一个实例。 Attributes: _instances (dict): 存储类的实例的字典 """ _instances = {}
[文档] def __call__(cls, *args, **kwargs): """创建或返回类的单例实例。 Args: *args: 位置参数 **kwargs: 关键字参数 Returns: object: 类的单例实例 """ if cls not in cls._instances: cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) return cls._instances[cls]
[文档] class Protect(metaclass=Singleton): """保护类,风控保护机制。 使用单例模式确保全局只有一个实例。 Attributes: login_stat_path (str): 登录状态文件的路径 login_stat (dict): 登录状态信息 login_time (int): 最后登录时间戳 login_device_id (str): 最后登录的设备ID """
[文档] def __init__(self): """初始化保护类实例。 创建或加载登录状态文件,初始化登录时间和设备ID。 """ self.login_stat_path = os.path.join(os.path.dirname(__file__), "login_stat.json") if not os.path.exists(self.login_stat_path): default_config = { "login_time": 0, "device_id": "" } with open(self.login_stat_path, "w", encoding="utf-8") as f: f.write(json.dumps(default_config, indent=4, ensure_ascii=False)) self.login_stat = default_config else: with open(self.login_stat_path, "r", encoding="utf-8") as f: self.login_stat = json.loads(f.read()) self.login_time = self.login_stat.get("login_time", 0) self.login_device_id = self.login_stat.get("device_id", "")
[文档] def check(self, second: int) -> bool: """检查是否在指定时间内,风控保护。 Args: second (int): 指定的秒数 Returns: bool: 如果当前时间与上次登录时间的差小于指定秒数,返回True;否则返回False """ now = datetime.now().timestamp() return now - self.login_time < second
[文档] def update_login_status(self, device_id: str = ""): """更新登录状态。 如果设备ID发生变化,更新登录时间和设备ID,并保存到文件。 Args: device_id (str, optional): 设备ID. Defaults to "". """ if device_id == self.login_device_id: return self.login_time = int(datetime.now().timestamp()) self.login_stat["login_time"] = self.login_time self.login_stat["device_id"] = device_id with open(self.login_stat_path, "w", encoding="utf-8") as f: f.write(json.dumps(self.login_stat, indent=4, ensure_ascii=False))
protector = Protect()
================================================ FILE: docs/WechatAPIClient/_modules/WechatAPI/Client/tool.html ================================================ WechatAPI.Client.tool - XYBotV2 Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
Back to top

WechatAPI.Client.tool 源代码

import base64
import io
import os

import aiohttp
import pysilk
from pydub import AudioSegment

from .base import *
from .protect import protector
from ..errors import *


[文档] class ToolMixin(WechatAPIClientBase):
[文档] async def download_image(self, aeskey: str, cdnmidimgurl: str) -> str: """CDN下载高清图片。 Args: aeskey (str): 图片的AES密钥 cdnmidimgurl (str): 图片的CDN URL Returns: str: 图片的base64编码字符串 Raises: UserLoggedOut: 未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "AesKey": aeskey, "Cdnmidimgurl": cdnmidimgurl} response = await session.post(f'http://{self.ip}:{self.port}/CdnDownloadImg', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data") else: self.error_handler(json_resp)
[文档] async def download_voice(self, msg_id: str, voiceurl: str, length: int) -> str: """下载语音文件。 Args: msg_id (str): 消息的msgid voiceurl (str): 语音的url,从xml获取 length (int): 语音长度,从xml获取 Returns: str: 语音的base64编码字符串 Raises: UserLoggedOut: 未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "MsgId": msg_id, "Voiceurl": voiceurl, "Length": length} response = await session.post(f'http://{self.ip}:{self.port}/DownloadVoice', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("data").get("buffer") else: self.error_handler(json_resp)
[文档] async def download_attach(self, attach_id: str) -> dict: """下载附件。 Args: attach_id (str): 附件ID Returns: dict: 附件数据 Raises: UserLoggedOut: 未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "AttachId": attach_id} response = await session.post(f'http://{self.ip}:{self.port}/DownloadAttach', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("data").get("buffer") else: self.error_handler(json_resp)
[文档] async def download_video(self, msg_id) -> str: """下载视频。 Args: msg_id (str): 消息的msg_id Returns: str: 视频的base64编码字符串 Raises: UserLoggedOut: 未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "MsgId": msg_id} response = await session.post(f'http://{self.ip}:{self.port}/DownloadVideo', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("data").get("buffer") else: self.error_handler(json_resp)
[文档] async def set_step(self, count: int) -> bool: """设置步数。 Args: count (int): 要设置的步数 Returns: bool: 成功返回True,失败返回False Raises: UserLoggedOut: 未登录时调用 BanProtection: 风控保护: 新设备登录后4小时内请挂机 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") elif not self.ignore_protect and protector.check(14400): raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "StepCount": count} response = await session.post(f'http://{self.ip}:{self.port}/SetStep', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp)
[文档] async def set_proxy(self, proxy: Proxy) -> bool: """设置代理。 Args: proxy (Proxy): 代理配置对象 Returns: bool: 成功返回True,失败返回False Raises: UserLoggedOut: 未登录时调用 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Proxy": {"ProxyIp": f"{proxy.ip}:{proxy.port}", "ProxyUser": proxy.username, "ProxyPassword": proxy.password}} response = await session.post(f'http://{self.ip}:{self.port}/SetProxy', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return True else: self.error_handler(json_resp)
[文档] async def check_database(self) -> bool: """检查数据库状态。 Returns: bool: 数据库正常返回True,否则返回False """ async with aiohttp.ClientSession() as session: response = await session.get(f'http://{self.ip}:{self.port}/CheckDatabaseOK') json_resp = await response.json() if json_resp.get("Running"): return True else: return False
[文档] @staticmethod def base64_to_file(base64_str: str, file_name: str, file_path: str) -> bool: """将base64字符串转换为文件并保存。 Args: base64_str (str): base64编码的字符串 file_name (str): 要保存的文件名 file_path (str): 文件保存路径 Returns: bool: 转换成功返回True,失败返回False """ try: os.makedirs(file_path, exist_ok=True) # 拼接完整的文件路径 full_path = os.path.join(file_path, file_name) # 移除可能存在的 base64 头部信息 if ',' in base64_str: base64_str = base64_str.split(',')[1] # 解码 base64 并写入文件 with open(full_path, 'wb') as f: f.write(base64.b64decode(base64_str)) return True except Exception as e: return False
[文档] @staticmethod def file_to_base64(file_path: str) -> str: """将文件转换为base64字符串。 Args: file_path (str): 文件路径 Returns: str: base64编码的字符串 """ with open(file_path, 'rb') as f: return base64.b64encode(f.read()).decode()
[文档] @staticmethod def base64_to_byte(base64_str: str) -> bytes: """将base64字符串转换为bytes。 Args: base64_str (str): base64编码的字符串 Returns: bytes: 解码后的字节数据 """ # 移除可能存在的 base64 头部信息 if ',' in base64_str: base64_str = base64_str.split(',')[1] return base64.b64decode(base64_str)
[文档] @staticmethod def byte_to_base64(byte: bytes) -> str: """将bytes转换为base64字符串。 Args: byte (bytes): 字节数据 Returns: str: base64编码的字符串 """ return base64.b64encode(byte).decode("utf-8")
[文档] @staticmethod async def silk_byte_to_byte_wav_byte(silk_byte: bytes) -> bytes: """将silk字节转换为wav字节。 Args: silk_byte (bytes): silk格式的字节数据 Returns: bytes: wav格式的字节数据 """ return await pysilk.async_decode(silk_byte, to_wav=True)
[文档] @staticmethod def wav_byte_to_amr_byte(wav_byte: bytes) -> bytes: """将WAV字节数据转换为AMR格式。 Args: wav_byte (bytes): WAV格式的字节数据 Returns: bytes: AMR格式的字节数据 Raises: Exception: 转换失败时抛出异常 """ try: # 从字节数据创建 AudioSegment 对象 audio = AudioSegment.from_wav(io.BytesIO(wav_byte)) # 设置 AMR 编码的标准参数 audio = audio.set_frame_rate(8000).set_channels(1) # 创建一个字节缓冲区来存储 AMR 数据 output = io.BytesIO() # 导出为 AMR 格式 audio.export(output, format="amr") # 获取字节数据 return output.getvalue() except Exception as e: raise Exception(f"转换WAV到AMR失败: {str(e)}")
[文档] @staticmethod def wav_byte_to_amr_base64(wav_byte: bytes) -> str: """将WAV字节数据转换为AMR格式的base64字符串。 Args: wav_byte (bytes): WAV格式的字节数据 Returns: str: AMR格式的base64编码字符串 """ return base64.b64encode(ToolMixin.wav_byte_to_amr_byte(wav_byte)).decode()
[文档] @staticmethod async def wav_byte_to_silk_byte(wav_byte: bytes) -> bytes: """将WAV字节数据转换为silk格式。 Args: wav_byte (bytes): WAV格式的字节数据 Returns: bytes: silk格式的字节数据 """ # get pcm data audio = AudioSegment.from_wav(io.BytesIO(wav_byte)) pcm = audio.raw_data return await pysilk.async_encode(pcm, data_rate=audio.frame_rate, sample_rate=audio.frame_rate)
[文档] @staticmethod async def wav_byte_to_silk_base64(wav_byte: bytes) -> str: """将WAV字节数据转换为silk格式的base64字符串。 Args: wav_byte (bytes): WAV格式的字节数据 Returns: str: silk格式的base64编码字符串 """ return base64.b64encode(await ToolMixin.wav_byte_to_silk_byte(wav_byte)).decode()
[文档] @staticmethod async def silk_base64_to_wav_byte(silk_base64: str) -> bytes: """将silk格式的base64字符串转换为WAV字节数据。 Args: silk_base64 (str): silk格式的base64编码字符串 Returns: bytes: WAV格式的字节数据 """ return await ToolMixin.silk_byte_to_byte_wav_byte(base64.b64decode(silk_base64))
================================================ FILE: docs/WechatAPIClient/_modules/WechatAPI/Client/user.html ================================================ WechatAPI.Client.user - XYBotV2 Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
Back to top

WechatAPI.Client.user 源代码

import aiohttp

from .base import *
from .protect import protector
from ..errors import *


[文档] class UserMixin(WechatAPIClientBase):
[文档] async def get_profile(self, wxid: str = None) -> dict: """获取用户信息。 Args: wxid (str, optional): 用户wxid. Defaults to None. Returns: dict: 用户信息字典 Raises: UserLoggedOut: 未登录时调用 根据error_handler处理错误 """ if not self.wxid and not wxid: raise UserLoggedOut("请先登录") if not wxid: wxid = self.wxid async with aiohttp.ClientSession() as session: json_param = {"Wxid": wxid} response = await session.post(f'http://{self.ip}:{self.port}/GetProfile', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("userInfo") else: self.error_handler(json_resp)
[文档] async def get_my_qrcode(self, style: int = 0) -> str: """获取个人二维码。 Args: style (int, optional): 二维码样式. Defaults to 0. Returns: str: 图片的base64编码字符串 Raises: UserLoggedOut: 未登录时调用 BanProtection: 风控保护: 新设备登录后4小时内请挂机 根据error_handler处理错误 """ if not self.wxid: raise UserLoggedOut("请先登录") elif protector.check(14400) and not self.ignore_protect: raise BanProtection("风控保护: 新设备登录后4小时内请挂机") async with aiohttp.ClientSession() as session: json_param = {"Wxid": self.wxid, "Style": style} response = await session.post(f'http://{self.ip}:{self.port}/GetMyQRCode', json=json_param) json_resp = await response.json() if json_resp.get("Success"): return json_resp.get("Data").get("qrcode").get("buffer") else: self.error_handler(json_resp)
[文档] async def is_logged_in(self, wxid: str = None) -> bool: """检查是否登录。 Args: wxid (str, optional): 用户wxid. Defaults to None. Returns: bool: 已登录返回True,未登录返回False """ if not wxid: wxid = self.wxid try: await self.get_profile(wxid) return True except: return False
================================================ FILE: docs/WechatAPIClient/_modules/index.html ================================================ 概览:模块代码 - XYBotV2 Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
Back to top
================================================ FILE: docs/WechatAPIClient/_sources/index.rst.txt ================================================ WechatAPIClient ------------------------------ 基础 ~~~~~~~ .. automodule:: WechatAPI.Client.base :members: :undoc-members: :show-inheritance: 登录 ~~~~~~~ .. automodule:: WechatAPI.Client.login :members: :undoc-members: :show-inheritance: 消息 ~~~~~~~ .. automodule:: WechatAPI.Client.message :members: :undoc-members: :show-inheritance: 用户 ~~~~~~~ .. automodule:: WechatAPI.Client.user :members: :undoc-members: :show-inheritance: 群聊 ~~~~~~~ .. automodule:: WechatAPI.Client.chatroom :members: :undoc-members: :show-inheritance: 好友 ~~~~~~~ .. automodule:: WechatAPI.Client.friend :members: :undoc-members: :show-inheritance: 红包 ~~~~~~~ .. automodule:: WechatAPI.Client.hongbao :members: :undoc-members: :show-inheritance: 保护 ~~~~~~~ .. automodule:: WechatAPI.Client.protect :members: :undoc-members: :show-inheritance: 工具 ~~~~~~~ .. automodule:: WechatAPI.Client.tool :members: :undoc-members: :show-inheritance: 索引 ==== * :ref:`search` ================================================ FILE: docs/WechatAPIClient/_static/basic.css ================================================ /* * Sphinx stylesheet -- basic theme. */ /* -- main layout ----------------------------------------------------------- */ div.clearer { clear: both; } div.section::after { display: block; content: ''; clear: left; } /* -- relbar ---------------------------------------------------------------- */ div.related { width: 100%; font-size: 90%; } div.related h3 { display: none; } div.related ul { margin: 0; padding: 0 0 0 10px; list-style: none; } div.related li { display: inline; } div.related li.right { float: right; margin-right: 5px; } /* -- sidebar --------------------------------------------------------------- */ div.sphinxsidebarwrapper { padding: 10px 5px 0 10px; } div.sphinxsidebar { float: left; width: 230px; margin-left: -100%; font-size: 90%; word-wrap: break-word; overflow-wrap: break-word; } div.sphinxsidebar ul { list-style: none; } div.sphinxsidebar ul ul, div.sphinxsidebar ul.want-points { margin-left: 20px; list-style: square; } div.sphinxsidebar ul ul { margin-top: 0; margin-bottom: 0; } div.sphinxsidebar form { margin-top: 10px; } div.sphinxsidebar input { border: 1px solid #98dbcc; font-family: sans-serif; font-size: 1em; } div.sphinxsidebar #searchbox form.search { overflow: hidden; } div.sphinxsidebar #searchbox input[type="text"] { float: left; width: 80%; padding: 0.25em; box-sizing: border-box; } div.sphinxsidebar #searchbox input[type="submit"] { float: left; width: 20%; border-left: none; padding: 0.25em; box-sizing: border-box; } img { border: 0; max-width: 100%; } /* -- search page ----------------------------------------------------------- */ ul.search { margin-top: 10px; } ul.search li { padding: 5px 0; } ul.search li a { font-weight: bold; } ul.search li p.context { color: #888; margin: 2px 0 0 30px; text-align: left; } ul.keywordmatches li.goodmatch a { font-weight: bold; } /* -- index page ------------------------------------------------------------ */ table.contentstable { width: 90%; margin-left: auto; margin-right: auto; } table.contentstable p.biglink { line-height: 150%; } a.biglink { font-size: 1.3em; } span.linkdescr { font-style: italic; padding-top: 5px; font-size: 90%; } /* -- general index --------------------------------------------------------- */ table.indextable { width: 100%; } table.indextable td { text-align: left; vertical-align: top; } table.indextable ul { margin-top: 0; margin-bottom: 0; list-style-type: none; } table.indextable > tbody > tr > td > ul { padding-left: 0em; } table.indextable tr.pcap { height: 10px; } table.indextable tr.cap { margin-top: 10px; background-color: #f2f2f2; } img.toggler { margin-right: 3px; margin-top: 3px; cursor: pointer; } div.modindex-jumpbox { border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; margin: 1em 0 1em 0; padding: 0.4em; } div.genindex-jumpbox { border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; margin: 1em 0 1em 0; padding: 0.4em; } /* -- domain module index --------------------------------------------------- */ table.modindextable td { padding: 2px; border-collapse: collapse; } /* -- general body styles --------------------------------------------------- */ div.body { min-width: 360px; max-width: 800px; } div.body p, div.body dd, div.body li, div.body blockquote { -moz-hyphens: auto; -ms-hyphens: auto; -webkit-hyphens: auto; hyphens: auto; } a.headerlink { visibility: hidden; } a:visited { color: #551A8B; } h1:hover > a.headerlink, h2:hover > a.headerlink, h3:hover > a.headerlink, h4:hover > a.headerlink, h5:hover > a.headerlink, h6:hover > a.headerlink, dt:hover > a.headerlink, caption:hover > a.headerlink, p.caption:hover > a.headerlink, div.code-block-caption:hover > a.headerlink { visibility: visible; } div.body p.caption { text-align: inherit; } div.body td { text-align: left; } .first { margin-top: 0 !important; } p.rubric { margin-top: 30px; font-weight: bold; } img.align-left, figure.align-left, .figure.align-left, object.align-left { clear: left; float: left; margin-right: 1em; } img.align-right, figure.align-right, .figure.align-right, object.align-right { clear: right; float: right; margin-left: 1em; } img.align-center, figure.align-center, .figure.align-center, object.align-center { display: block; margin-left: auto; margin-right: auto; } img.align-default, figure.align-default, .figure.align-default { display: block; margin-left: auto; margin-right: auto; } .align-left { text-align: left; } .align-center { text-align: center; } .align-default { text-align: center; } .align-right { text-align: right; } /* -- sidebars -------------------------------------------------------------- */ div.sidebar, aside.sidebar { margin: 0 0 0.5em 1em; border: 1px solid #ddb; padding: 7px; background-color: #ffe; width: 40%; float: right; clear: right; overflow-x: auto; } p.sidebar-title { font-weight: bold; } nav.contents, aside.topic, div.admonition, div.topic, blockquote { clear: left; } /* -- topics ---------------------------------------------------------------- */ nav.contents, aside.topic, div.topic { border: 1px solid #ccc; padding: 7px; margin: 10px 0 10px 0; } p.topic-title { font-size: 1.1em; font-weight: bold; margin-top: 10px; } /* -- admonitions ----------------------------------------------------------- */ div.admonition { margin-top: 10px; margin-bottom: 10px; padding: 7px; } div.admonition dt { font-weight: bold; } p.admonition-title { margin: 0px 10px 5px 0px; font-weight: bold; } div.body p.centered { text-align: center; margin-top: 25px; } /* -- content of sidebars/topics/admonitions -------------------------------- */ div.sidebar > :last-child, aside.sidebar > :last-child, nav.contents > :last-child, aside.topic > :last-child, div.topic > :last-child, div.admonition > :last-child { margin-bottom: 0; } div.sidebar::after, aside.sidebar::after, nav.contents::after, aside.topic::after, div.topic::after, div.admonition::after, blockquote::after { display: block; content: ''; clear: both; } /* -- tables ---------------------------------------------------------------- */ table.docutils { margin-top: 10px; margin-bottom: 10px; border: 0; border-collapse: collapse; } table.align-center { margin-left: auto; margin-right: auto; } table.align-default { margin-left: auto; margin-right: auto; } table caption span.caption-number { font-style: italic; } table caption span.caption-text { } table.docutils td, table.docutils th { padding: 1px 8px 1px 5px; border-top: 0; border-left: 0; border-right: 0; border-bottom: 1px solid #aaa; } th { text-align: left; padding-right: 5px; } table.citation { border-left: solid 1px gray; margin-left: 1px; } table.citation td { border-bottom: none; } th > :first-child, td > :first-child { margin-top: 0px; } th > :last-child, td > :last-child { margin-bottom: 0px; } /* -- figures --------------------------------------------------------------- */ div.figure, figure { margin: 0.5em; padding: 0.5em; } div.figure p.caption, figcaption { padding: 0.3em; } div.figure p.caption span.caption-number, figcaption span.caption-number { font-style: italic; } div.figure p.caption span.caption-text, figcaption span.caption-text { } /* -- field list styles ----------------------------------------------------- */ table.field-list td, table.field-list th { border: 0 !important; } .field-list ul { margin: 0; padding-left: 1em; } .field-list p { margin: 0; } .field-name { -moz-hyphens: manual; -ms-hyphens: manual; -webkit-hyphens: manual; hyphens: manual; } /* -- hlist styles ---------------------------------------------------------- */ table.hlist { margin: 1em 0; } table.hlist td { vertical-align: top; } /* -- object description styles --------------------------------------------- */ .sig { font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace; } .sig-name, code.descname { background-color: transparent; font-weight: bold; } .sig-name { font-size: 1.1em; } code.descname { font-size: 1.2em; } .sig-prename, code.descclassname { background-color: transparent; } .optional { font-size: 1.3em; } .sig-paren { font-size: larger; } .sig-param.n { font-style: italic; } /* C++ specific styling */ .sig-inline.c-texpr, .sig-inline.cpp-texpr { font-family: unset; } .sig.c .k, .sig.c .kt, .sig.cpp .k, .sig.cpp .kt { color: #0033B3; } .sig.c .m, .sig.cpp .m { color: #1750EB; } .sig.c .s, .sig.c .sc, .sig.cpp .s, .sig.cpp .sc { color: #067D17; } /* -- other body styles ----------------------------------------------------- */ ol.arabic { list-style: decimal; } ol.loweralpha { list-style: lower-alpha; } ol.upperalpha { list-style: upper-alpha; } ol.lowerroman { list-style: lower-roman; } ol.upperroman { list-style: upper-roman; } :not(li) > ol > li:first-child > :first-child, :not(li) > ul > li:first-child > :first-child { margin-top: 0px; } :not(li) > ol > li:last-child > :last-child, :not(li) > ul > li:last-child > :last-child { margin-bottom: 0px; } ol.simple ol p, ol.simple ul p, ul.simple ol p, ul.simple ul p { margin-top: 0; } ol.simple > li:not(:first-child) > p, ul.simple > li:not(:first-child) > p { margin-top: 0; } ol.simple p, ul.simple p { margin-bottom: 0; } aside.footnote > span, div.citation > span { float: left; } aside.footnote > span:last-of-type, div.citation > span:last-of-type { padding-right: 0.5em; } aside.footnote > p { margin-left: 2em; } div.citation > p { margin-left: 4em; } aside.footnote > p:last-of-type, div.citation > p:last-of-type { margin-bottom: 0em; } aside.footnote > p:last-of-type:after, div.citation > p:last-of-type:after { content: ""; clear: both; } dl.field-list { display: grid; grid-template-columns: fit-content(30%) auto; } dl.field-list > dt { font-weight: bold; word-break: break-word; padding-left: 0.5em; padding-right: 5px; } dl.field-list > dd { padding-left: 0.5em; margin-top: 0em; margin-left: 0em; margin-bottom: 0em; } dl { margin-bottom: 15px; } dd > :first-child { margin-top: 0px; } dd ul, dd table { margin-bottom: 10px; } dd { margin-top: 3px; margin-bottom: 10px; margin-left: 30px; } .sig dd { margin-top: 0px; margin-bottom: 0px; } .sig dl { margin-top: 0px; margin-bottom: 0px; } dl > dd:last-child, dl > dd:last-child > :last-child { margin-bottom: 0; } dt:target, span.highlighted { background-color: #fbe54e; } rect.highlighted { fill: #fbe54e; } dl.glossary dt { font-weight: bold; font-size: 1.1em; } .versionmodified { font-style: italic; } .system-message { background-color: #fda; padding: 5px; border: 3px solid red; } .footnote:target { background-color: #ffa; } .line-block { display: block; margin-top: 1em; margin-bottom: 1em; } .line-block .line-block { margin-top: 0; margin-bottom: 0; margin-left: 1.5em; } .guilabel, .menuselection { font-family: sans-serif; } .accelerator { text-decoration: underline; } .classifier { font-style: oblique; } .classifier:before { font-style: normal; margin: 0 0.5em; content: ":"; display: inline-block; } abbr, acronym { border-bottom: dotted 1px; cursor: help; } .translated { background-color: rgba(207, 255, 207, 0.2) } .untranslated { background-color: rgba(255, 207, 207, 0.2) } /* -- code displays --------------------------------------------------------- */ pre { overflow: auto; overflow-y: hidden; /* fixes display issues on Chrome browsers */ } pre, div[class*="highlight-"] { clear: both; } span.pre { -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; white-space: nowrap; } div[class*="highlight-"] { margin: 1em 0; } td.linenos pre { border: 0; background-color: transparent; color: #aaa; } table.highlighttable { display: block; } table.highlighttable tbody { display: block; } table.highlighttable tr { display: flex; } table.highlighttable td { margin: 0; padding: 0; } table.highlighttable td.linenos { padding-right: 0.5em; } table.highlighttable td.code { flex: 1; overflow: hidden; } .highlight .hll { display: block; } div.highlight pre, table.highlighttable pre { margin: 0; } div.code-block-caption + div { margin-top: 0; } div.code-block-caption { margin-top: 1em; padding: 2px 5px; font-size: small; } div.code-block-caption code { background-color: transparent; } table.highlighttable td.linenos, span.linenos, div.highlight span.gp { /* gp: Generic.Prompt */ user-select: none; -webkit-user-select: text; /* Safari fallback only */ -webkit-user-select: none; /* Chrome/Safari */ -moz-user-select: none; /* Firefox */ -ms-user-select: none; /* IE10+ */ } div.code-block-caption span.caption-number { padding: 0.1em 0.3em; font-style: italic; } div.code-block-caption span.caption-text { } div.literal-block-wrapper { margin: 1em 0; } code.xref, a code { background-color: transparent; font-weight: bold; } h1 code, h2 code, h3 code, h4 code, h5 code, h6 code { background-color: transparent; } .viewcode-link { float: right; } .viewcode-back { float: right; font-family: sans-serif; } div.viewcode-block:target { margin: -1px -10px; padding: 0 10px; } /* -- math display ---------------------------------------------------------- */ img.math { vertical-align: middle; } div.body div.math p { text-align: center; } span.eqno { float: right; } span.eqno a.headerlink { position: absolute; z-index: 1; } div.math:hover a.headerlink { visibility: visible; } /* -- printout stylesheet --------------------------------------------------- */ @media print { div.document, div.documentwrapper, div.bodywrapper { margin: 0 !important; width: 100%; } div.sphinxsidebar, div.related, div.footer, #top-link { display: none; } } ================================================ FILE: docs/WechatAPIClient/_static/debug.css ================================================ /* This CSS file should be overridden by the theme authors. It's meant for debugging and developing the skeleton that this theme provides. */ body { font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; background: lavender; } .sb-announcement { background: rgb(131, 131, 131); } .sb-announcement__inner { background: black; color: white; } .sb-header { background: lightskyblue; } .sb-header__inner { background: royalblue; color: white; } .sb-header-secondary { background: lightcyan; } .sb-header-secondary__inner { background: cornflowerblue; color: white; } .sb-sidebar-primary { background: lightgreen; } .sb-main { background: blanchedalmond; } .sb-main__inner { background: antiquewhite; } .sb-header-article { background: lightsteelblue; } .sb-article-container { background: snow; } .sb-article-main { background: white; } .sb-footer-article { background: lightpink; } .sb-sidebar-secondary { background: lightgoldenrodyellow; } .sb-footer-content { background: plum; } .sb-footer-content__inner { background: palevioletred; } .sb-footer { background: pink; } .sb-footer__inner { background: salmon; } .sb-article { background: white; } ================================================ FILE: docs/WechatAPIClient/_static/doctools.js ================================================ /* * Base JavaScript utilities for all Sphinx HTML documentation. */ "use strict"; const BLACKLISTED_KEY_CONTROL_ELEMENTS = new Set([ "TEXTAREA", "INPUT", "SELECT", "BUTTON", ]); const _ready = (callback) => { if (document.readyState !== "loading") { callback(); } else { document.addEventListener("DOMContentLoaded", callback); } }; /** * Small JavaScript module for the documentation. */ const Documentation = { init: () => { Documentation.initDomainIndexTable(); Documentation.initOnKeyListeners(); }, /** * i18n support */ TRANSLATIONS: {}, PLURAL_EXPR: (n) => (n === 1 ? 0 : 1), LOCALE: "unknown", // gettext and ngettext don't access this so that the functions // can safely bound to a different name (_ = Documentation.gettext) gettext: (string) => { const translated = Documentation.TRANSLATIONS[string]; switch (typeof translated) { case "undefined": return string; // no translation case "string": return translated; // translation exists default: return translated[0]; // (singular, plural) translation tuple exists } }, ngettext: (singular, plural, n) => { const translated = Documentation.TRANSLATIONS[singular]; if (typeof translated !== "undefined") return translated[Documentation.PLURAL_EXPR(n)]; return n === 1 ? singular : plural; }, addTranslations: (catalog) => { Object.assign(Documentation.TRANSLATIONS, catalog.messages); Documentation.PLURAL_EXPR = new Function( "n", `return (${catalog.plural_expr})` ); Documentation.LOCALE = catalog.locale; }, /** * helper function to focus on search bar */ focusSearchBar: () => { document.querySelectorAll("input[name=q]")[0]?.focus(); }, /** * Initialise the domain index toggle buttons */ initDomainIndexTable: () => { const toggler = (el) => { const idNumber = el.id.substr(7); const toggledRows = document.querySelectorAll(`tr.cg-${idNumber}`); if (el.src.substr(-9) === "minus.png") { el.src = `${el.src.substr(0, el.src.length - 9)}plus.png`; toggledRows.forEach((el) => (el.style.display = "none")); } else { el.src = `${el.src.substr(0, el.src.length - 8)}minus.png`; toggledRows.forEach((el) => (el.style.display = "")); } }; const togglerElements = document.querySelectorAll("img.toggler"); togglerElements.forEach((el) => el.addEventListener("click", (event) => toggler(event.currentTarget)) ); togglerElements.forEach((el) => (el.style.display = "")); if (DOCUMENTATION_OPTIONS.COLLAPSE_INDEX) togglerElements.forEach(toggler); }, initOnKeyListeners: () => { // only install a listener if it is really needed if ( !DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS && !DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS ) return; document.addEventListener("keydown", (event) => { // bail for input elements if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) return; // bail with special keys if (event.altKey || event.ctrlKey || event.metaKey) return; if (!event.shiftKey) { switch (event.key) { case "ArrowLeft": if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; const prevLink = document.querySelector('link[rel="prev"]'); if (prevLink && prevLink.href) { window.location.href = prevLink.href; event.preventDefault(); } break; case "ArrowRight": if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; const nextLink = document.querySelector('link[rel="next"]'); if (nextLink && nextLink.href) { window.location.href = nextLink.href; event.preventDefault(); } break; } } // some keyboard layouts may need Shift to get / switch (event.key) { case "/": if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) break; Documentation.focusSearchBar(); event.preventDefault(); } }); }, }; // quick alias for translations const _ = Documentation.gettext; _ready(Documentation.init); ================================================ FILE: docs/WechatAPIClient/_static/documentation_options.js ================================================ const DOCUMENTATION_OPTIONS = { VERSION: '', LANGUAGE: 'zh-CN', COLLAPSE_INDEX: false, BUILDER: 'html', FILE_SUFFIX: '.html', LINK_SUFFIX: '.html', HAS_SOURCE: true, SOURCELINK_SUFFIX: '.txt', NAVIGATION_WITH_KEYS: true, SHOW_SEARCH_SUMMARY: true, ENABLE_SEARCH_SHORTCUTS: true, }; ================================================ FILE: docs/WechatAPIClient/_static/language_data.js ================================================ /* * This script contains the language-specific data used by searchtools.js, * namely the list of stopwords, stemmer, scorer and splitter. */ var stopwords = ["a", "and", "are", "as", "at", "be", "but", "by", "for", "if", "in", "into", "is", "it", "near", "no", "not", "of", "on", "or", "such", "that", "the", "their", "then", "there", "these", "they", "this", "to", "was", "will", "with"]; /* Non-minified version is copied as a separate JS file, if available */ /** * Porter Stemmer */ var Stemmer = function () { var step2list = { ational: 'ate', tional: 'tion', enci: 'ence', anci: 'ance', izer: 'ize', bli: 'ble', alli: 'al', entli: 'ent', eli: 'e', ousli: 'ous', ization: 'ize', ation: 'ate', ator: 'ate', alism: 'al', iveness: 'ive', fulness: 'ful', ousness: 'ous', aliti: 'al', iviti: 'ive', biliti: 'ble', logi: 'log' }; var step3list = { icate: 'ic', ative: '', alize: 'al', iciti: 'ic', ical: 'ic', ful: '', ness: '' }; var c = "[^aeiou]"; // consonant var v = "[aeiouy]"; // vowel var C = c + "[^aeiouy]*"; // consonant sequence var V = v + "[aeiou]*"; // vowel sequence var mgr0 = "^(" + C + ")?" + V + C; // [C]VC... is m>0 var meq1 = "^(" + C + ")?" + V + C + "(" + V + ")?$"; // [C]VC[V] is m=1 var mgr1 = "^(" + C + ")?" + V + C + V + C; // [C]VCVC... is m>1 var s_v = "^(" + C + ")?" + v; // vowel in stem this.stemWord = function (w) { var stem; var suffix; var firstch; var origword = w; if (w.length < 3) return w; var re; var re2; var re3; var re4; firstch = w.substr(0, 1); if (firstch == "y") w = firstch.toUpperCase() + w.substr(1); // Step 1a re = /^(.+?)(ss|i)es$/; re2 = /^(.+?)([^s])s$/; if (re.test(w)) w = w.replace(re, "$1$2"); else if (re2.test(w)) w = w.replace(re2, "$1$2"); // Step 1b re = /^(.+?)eed$/; re2 = /^(.+?)(ed|ing)$/; if (re.test(w)) { var fp = re.exec(w); re = new RegExp(mgr0); if (re.test(fp[1])) { re = /.$/; w = w.replace(re, ""); } } else if (re2.test(w)) { var fp = re2.exec(w); stem = fp[1]; re2 = new RegExp(s_v); if (re2.test(stem)) { w = stem; re2 = /(at|bl|iz)$/; re3 = new RegExp("([^aeiouylsz])\\1$"); re4 = new RegExp("^" + C + v + "[^aeiouwxy]$"); if (re2.test(w)) w = w + "e"; else if (re3.test(w)) { re = /.$/; w = w.replace(re, ""); } else if (re4.test(w)) w = w + "e"; } } // Step 1c re = /^(.+?)y$/; if (re.test(w)) { var fp = re.exec(w); stem = fp[1]; re = new RegExp(s_v); if (re.test(stem)) w = stem + "i"; } // Step 2 re = /^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/; if (re.test(w)) { var fp = re.exec(w); stem = fp[1]; suffix = fp[2]; re = new RegExp(mgr0); if (re.test(stem)) w = stem + step2list[suffix]; } // Step 3 re = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/; if (re.test(w)) { var fp = re.exec(w); stem = fp[1]; suffix = fp[2]; re = new RegExp(mgr0); if (re.test(stem)) w = stem + step3list[suffix]; } // Step 4 re = /^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/; re2 = /^(.+?)(s|t)(ion)$/; if (re.test(w)) { var fp = re.exec(w); stem = fp[1]; re = new RegExp(mgr1); if (re.test(stem)) w = stem; } else if (re2.test(w)) { var fp = re2.exec(w); stem = fp[1] + fp[2]; re2 = new RegExp(mgr1); if (re2.test(stem)) w = stem; } // Step 5 re = /^(.+?)e$/; if (re.test(w)) { var fp = re.exec(w); stem = fp[1]; re = new RegExp(mgr1); re2 = new RegExp(meq1); re3 = new RegExp("^" + C + v + "[^aeiouwxy]$"); if (re.test(stem) || (re2.test(stem) && !(re3.test(stem)))) w = stem; } re = /ll$/; re2 = new RegExp(mgr1); if (re.test(w) && re2.test(w)) { re = /.$/; w = w.replace(re, ""); } // and turn initial Y back to y if (firstch == "y") w = firstch.toLowerCase() + w.substr(1); return w; } } ================================================ FILE: docs/WechatAPIClient/_static/pygments.css ================================================ .highlight pre { line-height: 125%; } .highlight td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } .highlight span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } .highlight td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } .highlight span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } .highlight .hll { background-color: #ffffcc } .highlight { background: #f8f8f8; } .highlight .c { color: #8F5902; font-style: italic } /* Comment */ .highlight .err { color: #A40000; border: 1px solid #EF2929 } /* Error */ .highlight .g { color: #000 } /* Generic */ .highlight .k { color: #204A87; font-weight: bold } /* Keyword */ .highlight .l { color: #000 } /* Literal */ .highlight .n { color: #000 } /* Name */ .highlight .o { color: #CE5C00; font-weight: bold } /* Operator */ .highlight .x { color: #000 } /* Other */ .highlight .p { color: #000; font-weight: bold } /* Punctuation */ .highlight .ch { color: #8F5902; font-style: italic } /* Comment.Hashbang */ .highlight .cm { color: #8F5902; font-style: italic } /* Comment.Multiline */ .highlight .cp { color: #8F5902; font-style: italic } /* Comment.Preproc */ .highlight .cpf { color: #8F5902; font-style: italic } /* Comment.PreprocFile */ .highlight .c1 { color: #8F5902; font-style: italic } /* Comment.Single */ .highlight .cs { color: #8F5902; font-style: italic } /* Comment.Special */ .highlight .gd { color: #A40000 } /* Generic.Deleted */ .highlight .ge { color: #000; font-style: italic } /* Generic.Emph */ .highlight .ges { color: #000; font-weight: bold; font-style: italic } /* Generic.EmphStrong */ .highlight .gr { color: #EF2929 } /* Generic.Error */ .highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */ .highlight .gi { color: #00A000 } /* Generic.Inserted */ .highlight .go { color: #000; font-style: italic } /* Generic.Output */ .highlight .gp { color: #8F5902 } /* Generic.Prompt */ .highlight .gs { color: #000; font-weight: bold } /* Generic.Strong */ .highlight .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ .highlight .gt { color: #A40000; font-weight: bold } /* Generic.Traceback */ .highlight .kc { color: #204A87; font-weight: bold } /* Keyword.Constant */ .highlight .kd { color: #204A87; font-weight: bold } /* Keyword.Declaration */ .highlight .kn { color: #204A87; font-weight: bold } /* Keyword.Namespace */ .highlight .kp { color: #204A87; font-weight: bold } /* Keyword.Pseudo */ .highlight .kr { color: #204A87; font-weight: bold } /* Keyword.Reserved */ .highlight .kt { color: #204A87; font-weight: bold } /* Keyword.Type */ .highlight .ld { color: #000 } /* Literal.Date */ .highlight .m { color: #0000CF; font-weight: bold } /* Literal.Number */ .highlight .s { color: #4E9A06 } /* Literal.String */ .highlight .na { color: #C4A000 } /* Name.Attribute */ .highlight .nb { color: #204A87 } /* Name.Builtin */ .highlight .nc { color: #000 } /* Name.Class */ .highlight .no { color: #000 } /* Name.Constant */ .highlight .nd { color: #5C35CC; font-weight: bold } /* Name.Decorator */ .highlight .ni { color: #CE5C00 } /* Name.Entity */ .highlight .ne { color: #C00; font-weight: bold } /* Name.Exception */ .highlight .nf { color: #000 } /* Name.Function */ .highlight .nl { color: #F57900 } /* Name.Label */ .highlight .nn { color: #000 } /* Name.Namespace */ .highlight .nx { color: #000 } /* Name.Other */ .highlight .py { color: #000 } /* Name.Property */ .highlight .nt { color: #204A87; font-weight: bold } /* Name.Tag */ .highlight .nv { color: #000 } /* Name.Variable */ .highlight .ow { color: #204A87; font-weight: bold } /* Operator.Word */ .highlight .pm { color: #000; font-weight: bold } /* Punctuation.Marker */ .highlight .w { color: #F8F8F8 } /* Text.Whitespace */ .highlight .mb { color: #0000CF; font-weight: bold } /* Literal.Number.Bin */ .highlight .mf { color: #0000CF; font-weight: bold } /* Literal.Number.Float */ .highlight .mh { color: #0000CF; font-weight: bold } /* Literal.Number.Hex */ .highlight .mi { color: #0000CF; font-weight: bold } /* Literal.Number.Integer */ .highlight .mo { color: #0000CF; font-weight: bold } /* Literal.Number.Oct */ .highlight .sa { color: #4E9A06 } /* Literal.String.Affix */ .highlight .sb { color: #4E9A06 } /* Literal.String.Backtick */ .highlight .sc { color: #4E9A06 } /* Literal.String.Char */ .highlight .dl { color: #4E9A06 } /* Literal.String.Delimiter */ .highlight .sd { color: #8F5902; font-style: italic } /* Literal.String.Doc */ .highlight .s2 { color: #4E9A06 } /* Literal.String.Double */ .highlight .se { color: #4E9A06 } /* Literal.String.Escape */ .highlight .sh { color: #4E9A06 } /* Literal.String.Heredoc */ .highlight .si { color: #4E9A06 } /* Literal.String.Interpol */ .highlight .sx { color: #4E9A06 } /* Literal.String.Other */ .highlight .sr { color: #4E9A06 } /* Literal.String.Regex */ .highlight .s1 { color: #4E9A06 } /* Literal.String.Single */ .highlight .ss { color: #4E9A06 } /* Literal.String.Symbol */ .highlight .bp { color: #3465A4 } /* Name.Builtin.Pseudo */ .highlight .fm { color: #000 } /* Name.Function.Magic */ .highlight .vc { color: #000 } /* Name.Variable.Class */ .highlight .vg { color: #000 } /* Name.Variable.Global */ .highlight .vi { color: #000 } /* Name.Variable.Instance */ .highlight .vm { color: #000 } /* Name.Variable.Magic */ .highlight .il { color: #0000CF; font-weight: bold } /* Literal.Number.Integer.Long */ @media not print { body[data-theme="dark"] .highlight pre { line-height: 125%; } body[data-theme="dark"] .highlight td.linenos .normal { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } body[data-theme="dark"] .highlight span.linenos { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } body[data-theme="dark"] .highlight td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } body[data-theme="dark"] .highlight span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } body[data-theme="dark"] .highlight .hll { background-color: #404040 } body[data-theme="dark"] .highlight { background: #202020; color: #D0D0D0 } body[data-theme="dark"] .highlight .c { color: #ABABAB; font-style: italic } /* Comment */ body[data-theme="dark"] .highlight .err { color: #A61717; background-color: #E3D2D2 } /* Error */ body[data-theme="dark"] .highlight .esc { color: #D0D0D0 } /* Escape */ body[data-theme="dark"] .highlight .g { color: #D0D0D0 } /* Generic */ body[data-theme="dark"] .highlight .k { color: #6EBF26; font-weight: bold } /* Keyword */ body[data-theme="dark"] .highlight .l { color: #D0D0D0 } /* Literal */ body[data-theme="dark"] .highlight .n { color: #D0D0D0 } /* Name */ body[data-theme="dark"] .highlight .o { color: #D0D0D0 } /* Operator */ body[data-theme="dark"] .highlight .x { color: #D0D0D0 } /* Other */ body[data-theme="dark"] .highlight .p { color: #D0D0D0 } /* Punctuation */ body[data-theme="dark"] .highlight .ch { color: #ABABAB; font-style: italic } /* Comment.Hashbang */ body[data-theme="dark"] .highlight .cm { color: #ABABAB; font-style: italic } /* Comment.Multiline */ body[data-theme="dark"] .highlight .cp { color: #FF3A3A; font-weight: bold } /* Comment.Preproc */ body[data-theme="dark"] .highlight .cpf { color: #ABABAB; font-style: italic } /* Comment.PreprocFile */ body[data-theme="dark"] .highlight .c1 { color: #ABABAB; font-style: italic } /* Comment.Single */ body[data-theme="dark"] .highlight .cs { color: #E50808; font-weight: bold; background-color: #520000 } /* Comment.Special */ body[data-theme="dark"] .highlight .gd { color: #FF3A3A } /* Generic.Deleted */ body[data-theme="dark"] .highlight .ge { color: #D0D0D0; font-style: italic } /* Generic.Emph */ body[data-theme="dark"] .highlight .ges { color: #D0D0D0; font-weight: bold; font-style: italic } /* Generic.EmphStrong */ body[data-theme="dark"] .highlight .gr { color: #FF3A3A } /* Generic.Error */ body[data-theme="dark"] .highlight .gh { color: #FFF; font-weight: bold } /* Generic.Heading */ body[data-theme="dark"] .highlight .gi { color: #589819 } /* Generic.Inserted */ body[data-theme="dark"] .highlight .go { color: #CCC } /* Generic.Output */ body[data-theme="dark"] .highlight .gp { color: #AAA } /* Generic.Prompt */ body[data-theme="dark"] .highlight .gs { color: #D0D0D0; font-weight: bold } /* Generic.Strong */ body[data-theme="dark"] .highlight .gu { color: #FFF; text-decoration: underline } /* Generic.Subheading */ body[data-theme="dark"] .highlight .gt { color: #FF3A3A } /* Generic.Traceback */ body[data-theme="dark"] .highlight .kc { color: #6EBF26; font-weight: bold } /* Keyword.Constant */ body[data-theme="dark"] .highlight .kd { color: #6EBF26; font-weight: bold } /* Keyword.Declaration */ body[data-theme="dark"] .highlight .kn { color: #6EBF26; font-weight: bold } /* Keyword.Namespace */ body[data-theme="dark"] .highlight .kp { color: #6EBF26 } /* Keyword.Pseudo */ body[data-theme="dark"] .highlight .kr { color: #6EBF26; font-weight: bold } /* Keyword.Reserved */ body[data-theme="dark"] .highlight .kt { color: #6EBF26; font-weight: bold } /* Keyword.Type */ body[data-theme="dark"] .highlight .ld { color: #D0D0D0 } /* Literal.Date */ body[data-theme="dark"] .highlight .m { color: #51B2FD } /* Literal.Number */ body[data-theme="dark"] .highlight .s { color: #ED9D13 } /* Literal.String */ body[data-theme="dark"] .highlight .na { color: #BBB } /* Name.Attribute */ body[data-theme="dark"] .highlight .nb { color: #2FBCCD } /* Name.Builtin */ body[data-theme="dark"] .highlight .nc { color: #71ADFF; text-decoration: underline } /* Name.Class */ body[data-theme="dark"] .highlight .no { color: #40FFFF } /* Name.Constant */ body[data-theme="dark"] .highlight .nd { color: #FFA500 } /* Name.Decorator */ body[data-theme="dark"] .highlight .ni { color: #D0D0D0 } /* Name.Entity */ body[data-theme="dark"] .highlight .ne { color: #BBB } /* Name.Exception */ body[data-theme="dark"] .highlight .nf { color: #71ADFF } /* Name.Function */ body[data-theme="dark"] .highlight .nl { color: #D0D0D0 } /* Name.Label */ body[data-theme="dark"] .highlight .nn { color: #71ADFF; text-decoration: underline } /* Name.Namespace */ body[data-theme="dark"] .highlight .nx { color: #D0D0D0 } /* Name.Other */ body[data-theme="dark"] .highlight .py { color: #D0D0D0 } /* Name.Property */ body[data-theme="dark"] .highlight .nt { color: #6EBF26; font-weight: bold } /* Name.Tag */ body[data-theme="dark"] .highlight .nv { color: #40FFFF } /* Name.Variable */ body[data-theme="dark"] .highlight .ow { color: #6EBF26; font-weight: bold } /* Operator.Word */ body[data-theme="dark"] .highlight .pm { color: #D0D0D0 } /* Punctuation.Marker */ body[data-theme="dark"] .highlight .w { color: #666 } /* Text.Whitespace */ body[data-theme="dark"] .highlight .mb { color: #51B2FD } /* Literal.Number.Bin */ body[data-theme="dark"] .highlight .mf { color: #51B2FD } /* Literal.Number.Float */ body[data-theme="dark"] .highlight .mh { color: #51B2FD } /* Literal.Number.Hex */ body[data-theme="dark"] .highlight .mi { color: #51B2FD } /* Literal.Number.Integer */ body[data-theme="dark"] .highlight .mo { color: #51B2FD } /* Literal.Number.Oct */ body[data-theme="dark"] .highlight .sa { color: #ED9D13 } /* Literal.String.Affix */ body[data-theme="dark"] .highlight .sb { color: #ED9D13 } /* Literal.String.Backtick */ body[data-theme="dark"] .highlight .sc { color: #ED9D13 } /* Literal.String.Char */ body[data-theme="dark"] .highlight .dl { color: #ED9D13 } /* Literal.String.Delimiter */ body[data-theme="dark"] .highlight .sd { color: #ED9D13 } /* Literal.String.Doc */ body[data-theme="dark"] .highlight .s2 { color: #ED9D13 } /* Literal.String.Double */ body[data-theme="dark"] .highlight .se { color: #ED9D13 } /* Literal.String.Escape */ body[data-theme="dark"] .highlight .sh { color: #ED9D13 } /* Literal.String.Heredoc */ body[data-theme="dark"] .highlight .si { color: #ED9D13 } /* Literal.String.Interpol */ body[data-theme="dark"] .highlight .sx { color: #FFA500 } /* Literal.String.Other */ body[data-theme="dark"] .highlight .sr { color: #ED9D13 } /* Literal.String.Regex */ body[data-theme="dark"] .highlight .s1 { color: #ED9D13 } /* Literal.String.Single */ body[data-theme="dark"] .highlight .ss { color: #ED9D13 } /* Literal.String.Symbol */ body[data-theme="dark"] .highlight .bp { color: #2FBCCD } /* Name.Builtin.Pseudo */ body[data-theme="dark"] .highlight .fm { color: #71ADFF } /* Name.Function.Magic */ body[data-theme="dark"] .highlight .vc { color: #40FFFF } /* Name.Variable.Class */ body[data-theme="dark"] .highlight .vg { color: #40FFFF } /* Name.Variable.Global */ body[data-theme="dark"] .highlight .vi { color: #40FFFF } /* Name.Variable.Instance */ body[data-theme="dark"] .highlight .vm { color: #40FFFF } /* Name.Variable.Magic */ body[data-theme="dark"] .highlight .il { color: #51B2FD } /* Literal.Number.Integer.Long */ @media (prefers-color-scheme: dark) { body:not([data-theme="light"]) .highlight pre { line-height: 125%; } body:not([data-theme="light"]) .highlight td.linenos .normal { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } body:not([data-theme="light"]) .highlight span.linenos { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } body:not([data-theme="light"]) .highlight td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } body:not([data-theme="light"]) .highlight span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } body:not([data-theme="light"]) .highlight .hll { background-color: #404040 } body:not([data-theme="light"]) .highlight { background: #202020; color: #D0D0D0 } body:not([data-theme="light"]) .highlight .c { color: #ABABAB; font-style: italic } /* Comment */ body:not([data-theme="light"]) .highlight .err { color: #A61717; background-color: #E3D2D2 } /* Error */ body:not([data-theme="light"]) .highlight .esc { color: #D0D0D0 } /* Escape */ body:not([data-theme="light"]) .highlight .g { color: #D0D0D0 } /* Generic */ body:not([data-theme="light"]) .highlight .k { color: #6EBF26; font-weight: bold } /* Keyword */ body:not([data-theme="light"]) .highlight .l { color: #D0D0D0 } /* Literal */ body:not([data-theme="light"]) .highlight .n { color: #D0D0D0 } /* Name */ body:not([data-theme="light"]) .highlight .o { color: #D0D0D0 } /* Operator */ body:not([data-theme="light"]) .highlight .x { color: #D0D0D0 } /* Other */ body:not([data-theme="light"]) .highlight .p { color: #D0D0D0 } /* Punctuation */ body:not([data-theme="light"]) .highlight .ch { color: #ABABAB; font-style: italic } /* Comment.Hashbang */ body:not([data-theme="light"]) .highlight .cm { color: #ABABAB; font-style: italic } /* Comment.Multiline */ body:not([data-theme="light"]) .highlight .cp { color: #FF3A3A; font-weight: bold } /* Comment.Preproc */ body:not([data-theme="light"]) .highlight .cpf { color: #ABABAB; font-style: italic } /* Comment.PreprocFile */ body:not([data-theme="light"]) .highlight .c1 { color: #ABABAB; font-style: italic } /* Comment.Single */ body:not([data-theme="light"]) .highlight .cs { color: #E50808; font-weight: bold; background-color: #520000 } /* Comment.Special */ body:not([data-theme="light"]) .highlight .gd { color: #FF3A3A } /* Generic.Deleted */ body:not([data-theme="light"]) .highlight .ge { color: #D0D0D0; font-style: italic } /* Generic.Emph */ body:not([data-theme="light"]) .highlight .ges { color: #D0D0D0; font-weight: bold; font-style: italic } /* Generic.EmphStrong */ body:not([data-theme="light"]) .highlight .gr { color: #FF3A3A } /* Generic.Error */ body:not([data-theme="light"]) .highlight .gh { color: #FFF; font-weight: bold } /* Generic.Heading */ body:not([data-theme="light"]) .highlight .gi { color: #589819 } /* Generic.Inserted */ body:not([data-theme="light"]) .highlight .go { color: #CCC } /* Generic.Output */ body:not([data-theme="light"]) .highlight .gp { color: #AAA } /* Generic.Prompt */ body:not([data-theme="light"]) .highlight .gs { color: #D0D0D0; font-weight: bold } /* Generic.Strong */ body:not([data-theme="light"]) .highlight .gu { color: #FFF; text-decoration: underline } /* Generic.Subheading */ body:not([data-theme="light"]) .highlight .gt { color: #FF3A3A } /* Generic.Traceback */ body:not([data-theme="light"]) .highlight .kc { color: #6EBF26; font-weight: bold } /* Keyword.Constant */ body:not([data-theme="light"]) .highlight .kd { color: #6EBF26; font-weight: bold } /* Keyword.Declaration */ body:not([data-theme="light"]) .highlight .kn { color: #6EBF26; font-weight: bold } /* Keyword.Namespace */ body:not([data-theme="light"]) .highlight .kp { color: #6EBF26 } /* Keyword.Pseudo */ body:not([data-theme="light"]) .highlight .kr { color: #6EBF26; font-weight: bold } /* Keyword.Reserved */ body:not([data-theme="light"]) .highlight .kt { color: #6EBF26; font-weight: bold } /* Keyword.Type */ body:not([data-theme="light"]) .highlight .ld { color: #D0D0D0 } /* Literal.Date */ body:not([data-theme="light"]) .highlight .m { color: #51B2FD } /* Literal.Number */ body:not([data-theme="light"]) .highlight .s { color: #ED9D13 } /* Literal.String */ body:not([data-theme="light"]) .highlight .na { color: #BBB } /* Name.Attribute */ body:not([data-theme="light"]) .highlight .nb { color: #2FBCCD } /* Name.Builtin */ body:not([data-theme="light"]) .highlight .nc { color: #71ADFF; text-decoration: underline } /* Name.Class */ body:not([data-theme="light"]) .highlight .no { color: #40FFFF } /* Name.Constant */ body:not([data-theme="light"]) .highlight .nd { color: #FFA500 } /* Name.Decorator */ body:not([data-theme="light"]) .highlight .ni { color: #D0D0D0 } /* Name.Entity */ body:not([data-theme="light"]) .highlight .ne { color: #BBB } /* Name.Exception */ body:not([data-theme="light"]) .highlight .nf { color: #71ADFF } /* Name.Function */ body:not([data-theme="light"]) .highlight .nl { color: #D0D0D0 } /* Name.Label */ body:not([data-theme="light"]) .highlight .nn { color: #71ADFF; text-decoration: underline } /* Name.Namespace */ body:not([data-theme="light"]) .highlight .nx { color: #D0D0D0 } /* Name.Other */ body:not([data-theme="light"]) .highlight .py { color: #D0D0D0 } /* Name.Property */ body:not([data-theme="light"]) .highlight .nt { color: #6EBF26; font-weight: bold } /* Name.Tag */ body:not([data-theme="light"]) .highlight .nv { color: #40FFFF } /* Name.Variable */ body:not([data-theme="light"]) .highlight .ow { color: #6EBF26; font-weight: bold } /* Operator.Word */ body:not([data-theme="light"]) .highlight .pm { color: #D0D0D0 } /* Punctuation.Marker */ body:not([data-theme="light"]) .highlight .w { color: #666 } /* Text.Whitespace */ body:not([data-theme="light"]) .highlight .mb { color: #51B2FD } /* Literal.Number.Bin */ body:not([data-theme="light"]) .highlight .mf { color: #51B2FD } /* Literal.Number.Float */ body:not([data-theme="light"]) .highlight .mh { color: #51B2FD } /* Literal.Number.Hex */ body:not([data-theme="light"]) .highlight .mi { color: #51B2FD } /* Literal.Number.Integer */ body:not([data-theme="light"]) .highlight .mo { color: #51B2FD } /* Literal.Number.Oct */ body:not([data-theme="light"]) .highlight .sa { color: #ED9D13 } /* Literal.String.Affix */ body:not([data-theme="light"]) .highlight .sb { color: #ED9D13 } /* Literal.String.Backtick */ body:not([data-theme="light"]) .highlight .sc { color: #ED9D13 } /* Literal.String.Char */ body:not([data-theme="light"]) .highlight .dl { color: #ED9D13 } /* Literal.String.Delimiter */ body:not([data-theme="light"]) .highlight .sd { color: #ED9D13 } /* Literal.String.Doc */ body:not([data-theme="light"]) .highlight .s2 { color: #ED9D13 } /* Literal.String.Double */ body:not([data-theme="light"]) .highlight .se { color: #ED9D13 } /* Literal.String.Escape */ body:not([data-theme="light"]) .highlight .sh { color: #ED9D13 } /* Literal.String.Heredoc */ body:not([data-theme="light"]) .highlight .si { color: #ED9D13 } /* Literal.String.Interpol */ body:not([data-theme="light"]) .highlight .sx { color: #FFA500 } /* Literal.String.Other */ body:not([data-theme="light"]) .highlight .sr { color: #ED9D13 } /* Literal.String.Regex */ body:not([data-theme="light"]) .highlight .s1 { color: #ED9D13 } /* Literal.String.Single */ body:not([data-theme="light"]) .highlight .ss { color: #ED9D13 } /* Literal.String.Symbol */ body:not([data-theme="light"]) .highlight .bp { color: #2FBCCD } /* Name.Builtin.Pseudo */ body:not([data-theme="light"]) .highlight .fm { color: #71ADFF } /* Name.Function.Magic */ body:not([data-theme="light"]) .highlight .vc { color: #40FFFF } /* Name.Variable.Class */ body:not([data-theme="light"]) .highlight .vg { color: #40FFFF } /* Name.Variable.Global */ body:not([data-theme="light"]) .highlight .vi { color: #40FFFF } /* Name.Variable.Instance */ body:not([data-theme="light"]) .highlight .vm { color: #40FFFF } /* Name.Variable.Magic */ body:not([data-theme="light"]) .highlight .il { color: #51B2FD } /* Literal.Number.Integer.Long */ } } ================================================ FILE: docs/WechatAPIClient/_static/scripts/furo-extensions.js ================================================ ================================================ FILE: docs/WechatAPIClient/_static/scripts/furo.js ================================================ /*! For license information please see furo.js.LICENSE.txt */ (()=>{var t={856:function(t,e,n){var o,r;r=void 0!==n.g?n.g:"undefined"!=typeof window?window:this,o=function(){return function(t){"use strict";var e={navClass:"active",contentClass:"active",nested:!1,nestedClass:"active",offset:0,reflow:!1,events:!0},n=function(t,e,n){if(n.settings.events){var o=new CustomEvent(t,{bubbles:!0,cancelable:!0,detail:n});e.dispatchEvent(o)}},o=function(t){var e=0;if(t.offsetParent)for(;t;)e+=t.offsetTop,t=t.offsetParent;return e>=0?e:0},r=function(t){t&&t.sort((function(t,e){return o(t.content)=Math.max(document.body.scrollHeight,document.documentElement.scrollHeight,document.body.offsetHeight,document.documentElement.offsetHeight,document.body.clientHeight,document.documentElement.clientHeight)},l=function(t,e){var n=t[t.length-1];if(function(t,e){return!(!s()||!c(t.content,e,!0))}(n,e))return n;for(var o=t.length-1;o>=0;o--)if(c(t[o].content,e))return t[o]},a=function(t,e){if(e.nested&&t.parentNode){var n=t.parentNode.closest("li");n&&(n.classList.remove(e.nestedClass),a(n,e))}},i=function(t,e){if(t){var o=t.nav.closest("li");o&&(o.classList.remove(e.navClass),t.content.classList.remove(e.contentClass),a(o,e),n("gumshoeDeactivate",o,{link:t.nav,content:t.content,settings:e}))}},u=function(t,e){if(e.nested){var n=t.parentNode.closest("li");n&&(n.classList.add(e.nestedClass),u(n,e))}};return function(o,c){var s,a,d,f,m,v={setup:function(){s=document.querySelectorAll(o),a=[],Array.prototype.forEach.call(s,(function(t){var e=document.getElementById(decodeURIComponent(t.hash.substr(1)));e&&a.push({nav:t,content:e})})),r(a)},detect:function(){var t=l(a,m);t?d&&t.content===d.content||(i(d,m),function(t,e){if(t){var o=t.nav.closest("li");o&&(o.classList.add(e.navClass),t.content.classList.add(e.contentClass),u(o,e),n("gumshoeActivate",o,{link:t.nav,content:t.content,settings:e}))}}(t,m),d=t):d&&(i(d,m),d=null)}},h=function(e){f&&t.cancelAnimationFrame(f),f=t.requestAnimationFrame(v.detect)},g=function(e){f&&t.cancelAnimationFrame(f),f=t.requestAnimationFrame((function(){r(a),v.detect()}))};return v.destroy=function(){d&&i(d,m),t.removeEventListener("scroll",h,!1),m.reflow&&t.removeEventListener("resize",g,!1),a=null,s=null,d=null,f=null,m=null},m=function(){var t={};return Array.prototype.forEach.call(arguments,(function(e){for(var n in e){if(!e.hasOwnProperty(n))return;t[n]=e[n]}})),t}(e,c||{}),v.setup(),v.detect(),t.addEventListener("scroll",h,!1),m.reflow&&t.addEventListener("resize",g,!1),v}}(r)}.apply(e,[]),void 0===o||(t.exports=o)}},e={};function n(o){var r=e[o];if(void 0!==r)return r.exports;var c=e[o]={exports:{}};return t[o].call(c.exports,c,c.exports,n),c.exports}n.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return n.d(e,{a:e}),e},n.d=(t,e)=>{for(var o in e)n.o(e,o)&&!n.o(t,o)&&Object.defineProperty(t,o,{enumerable:!0,get:e[o]})},n.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}(),n.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{"use strict";var t=n(856),e=n.n(t),o=null,r=null,c=document.documentElement.scrollTop;const s=64;function l(){const t=localStorage.getItem("theme")||"auto";var e;"light"!==(e=window.matchMedia("(prefers-color-scheme: dark)").matches?"auto"===t?"light":"light"==t?"dark":"auto":"auto"===t?"dark":"dark"==t?"light":"auto")&&"dark"!==e&&"auto"!==e&&(console.error(`Got invalid theme mode: ${e}. Resetting to auto.`),e="auto"),document.body.dataset.theme=e,localStorage.setItem("theme",e),console.log(`Changed to ${e} mode.`)}function a(){!function(){const t=document.getElementsByClassName("theme-toggle");Array.from(t).forEach((t=>{t.addEventListener("click",l)}))}(),function(){let t=0,e=!1;window.addEventListener("scroll",(function(n){t=window.scrollY,e||(window.requestAnimationFrame((function(){var n;(function(t){const e=Math.floor(r.getBoundingClientRect().top);console.log(`headerTop: ${e}`),0==e&&t!=e?r.classList.add("scrolled"):r.classList.remove("scrolled")})(n=t),function(t){tc&&document.documentElement.classList.remove("show-back-to-top"),c=t}(n),function(t){null!==o&&(0==t?o.scrollTo(0,0):Math.ceil(t)>=Math.floor(document.documentElement.scrollHeight-window.innerHeight)?o.scrollTo(0,o.scrollHeight):document.querySelector(".scroll-current"))}(n),e=!1})),e=!0)})),window.scroll()}(),null!==o&&new(e())(".toc-tree a",{reflow:!0,recursive:!0,navClass:"scroll-current",offset:()=>{let t=parseFloat(getComputedStyle(document.documentElement).fontSize);return r.getBoundingClientRect().height+2.5*t+1}})}document.addEventListener("DOMContentLoaded",(function(){document.body.parentNode.classList.remove("no-js"),r=document.querySelector("header"),o=document.querySelector(".toc-scroll"),a()}))})()})(); //# sourceMappingURL=furo.js.map ================================================ FILE: docs/WechatAPIClient/_static/scripts/furo.js.LICENSE.txt ================================================ /*! * gumshoejs v5.1.2 (patched by @pradyunsg) * A simple, framework-agnostic scrollspy script. * (c) 2019 Chris Ferdinandi * MIT License * http://github.com/cferdinandi/gumshoe */ ================================================ FILE: docs/WechatAPIClient/_static/searchtools.js ================================================ /* * Sphinx JavaScript utilities for the full-text search. */ "use strict"; /** * Simple result scoring code. */ if (typeof Scorer === "undefined") { var Scorer = { // Implement the following function to further tweak the score for each result // The function takes a result array [docname, title, anchor, descr, score, filename] // and returns the new score. /* score: result => { const [docname, title, anchor, descr, score, filename, kind] = result return score }, */ // query matches the full name of an object objNameMatch: 11, // or matches in the last dotted part of the object name objPartialMatch: 6, // Additive scores depending on the priority of the object objPrio: { 0: 15, // used to be importantResults 1: 5, // used to be objectResults 2: -5, // used to be unimportantResults }, // Used when the priority is not in the mapping. objPrioDefault: 0, // query found in title title: 15, partialTitle: 7, // query found in terms term: 5, partialTerm: 2, }; } // Global search result kind enum, used by themes to style search results. class SearchResultKind { static get index() { return "index"; } static get object() { return "object"; } static get text() { return "text"; } static get title() { return "title"; } } const _removeChildren = (element) => { while (element && element.lastChild) element.removeChild(element.lastChild); }; /** * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping */ const _escapeRegExp = (string) => string.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string const _displayItem = (item, searchTerms, highlightTerms) => { const docBuilder = DOCUMENTATION_OPTIONS.BUILDER; const docFileSuffix = DOCUMENTATION_OPTIONS.FILE_SUFFIX; const docLinkSuffix = DOCUMENTATION_OPTIONS.LINK_SUFFIX; const showSearchSummary = DOCUMENTATION_OPTIONS.SHOW_SEARCH_SUMMARY; const contentRoot = document.documentElement.dataset.content_root; const [docName, title, anchor, descr, score, _filename, kind] = item; let listItem = document.createElement("li"); // Add a class representing the item's type: // can be used by a theme's CSS selector for styling // See SearchResultKind for the class names. listItem.classList.add(`kind-${kind}`); let requestUrl; let linkUrl; if (docBuilder === "dirhtml") { // dirhtml builder let dirname = docName + "/"; if (dirname.match(/\/index\/$/)) dirname = dirname.substring(0, dirname.length - 6); else if (dirname === "index/") dirname = ""; requestUrl = contentRoot + dirname; linkUrl = requestUrl; } else { // normal html builders requestUrl = contentRoot + docName + docFileSuffix; linkUrl = docName + docLinkSuffix; } let linkEl = listItem.appendChild(document.createElement("a")); linkEl.href = linkUrl + anchor; linkEl.dataset.score = score; linkEl.innerHTML = title; if (descr) { listItem.appendChild(document.createElement("span")).innerHTML = " (" + descr + ")"; // highlight search terms in the description if (SPHINX_HIGHLIGHT_ENABLED) // set in sphinx_highlight.js highlightTerms.forEach((term) => _highlightText(listItem, term, "highlighted")); } else if (showSearchSummary) fetch(requestUrl) .then((responseData) => responseData.text()) .then((data) => { if (data) listItem.appendChild( Search.makeSearchSummary(data, searchTerms, anchor) ); // highlight search terms in the summary if (SPHINX_HIGHLIGHT_ENABLED) // set in sphinx_highlight.js highlightTerms.forEach((term) => _highlightText(listItem, term, "highlighted")); }); Search.output.appendChild(listItem); }; const _finishSearch = (resultCount) => { Search.stopPulse(); Search.title.innerText = _("Search Results"); if (!resultCount) Search.status.innerText = Documentation.gettext( "Your search did not match any documents. Please make sure that all words are spelled correctly and that you've selected enough categories." ); else Search.status.innerText = Documentation.ngettext( "Search finished, found one page matching the search query.", "Search finished, found ${resultCount} pages matching the search query.", resultCount, ).replace('${resultCount}', resultCount); }; const _displayNextItem = ( results, resultCount, searchTerms, highlightTerms, ) => { // results left, load the summary and display it // this is intended to be dynamic (don't sub resultsCount) if (results.length) { _displayItem(results.pop(), searchTerms, highlightTerms); setTimeout( () => _displayNextItem(results, resultCount, searchTerms, highlightTerms), 5 ); } // search finished, update title and status message else _finishSearch(resultCount); }; // Helper function used by query() to order search results. // Each input is an array of [docname, title, anchor, descr, score, filename, kind]. // Order the results by score (in opposite order of appearance, since the // `_displayNextItem` function uses pop() to retrieve items) and then alphabetically. const _orderResultsByScoreThenName = (a, b) => { const leftScore = a[4]; const rightScore = b[4]; if (leftScore === rightScore) { // same score: sort alphabetically const leftTitle = a[1].toLowerCase(); const rightTitle = b[1].toLowerCase(); if (leftTitle === rightTitle) return 0; return leftTitle > rightTitle ? -1 : 1; // inverted is intentional } return leftScore > rightScore ? 1 : -1; }; /** * Default splitQuery function. Can be overridden in ``sphinx.search`` with a * custom function per language. * * The regular expression works by splitting the string on consecutive characters * that are not Unicode letters, numbers, underscores, or emoji characters. * This is the same as ``\W+`` in Python, preserving the surrogate pair area. */ if (typeof splitQuery === "undefined") { var splitQuery = (query) => query .split(/[^\p{Letter}\p{Number}_\p{Emoji_Presentation}]+/gu) .filter(term => term) // remove remaining empty strings } /** * Search Module */ const Search = { _index: null, _queued_query: null, _pulse_status: -1, htmlToText: (htmlString, anchor) => { const htmlElement = new DOMParser().parseFromString(htmlString, 'text/html'); for (const removalQuery of [".headerlink", "script", "style"]) { htmlElement.querySelectorAll(removalQuery).forEach((el) => { el.remove() }); } if (anchor) { const anchorContent = htmlElement.querySelector(`[role="main"] ${anchor}`); if (anchorContent) return anchorContent.textContent; console.warn( `Anchored content block not found. Sphinx search tries to obtain it via DOM query '[role=main] ${anchor}'. Check your theme or template.` ); } // if anchor not specified or not found, fall back to main content const docContent = htmlElement.querySelector('[role="main"]'); if (docContent) return docContent.textContent; console.warn( "Content block not found. Sphinx search tries to obtain it via DOM query '[role=main]'. Check your theme or template." ); return ""; }, init: () => { const query = new URLSearchParams(window.location.search).get("q"); document .querySelectorAll('input[name="q"]') .forEach((el) => (el.value = query)); if (query) Search.performSearch(query); }, loadIndex: (url) => (document.body.appendChild(document.createElement("script")).src = url), setIndex: (index) => { Search._index = index; if (Search._queued_query !== null) { const query = Search._queued_query; Search._queued_query = null; Search.query(query); } }, hasIndex: () => Search._index !== null, deferQuery: (query) => (Search._queued_query = query), stopPulse: () => (Search._pulse_status = -1), startPulse: () => { if (Search._pulse_status >= 0) return; const pulse = () => { Search._pulse_status = (Search._pulse_status + 1) % 4; Search.dots.innerText = ".".repeat(Search._pulse_status); if (Search._pulse_status >= 0) window.setTimeout(pulse, 500); }; pulse(); }, /** * perform a search for something (or wait until index is loaded) */ performSearch: (query) => { // create the required interface elements const searchText = document.createElement("h2"); searchText.textContent = _("Searching"); const searchSummary = document.createElement("p"); searchSummary.classList.add("search-summary"); searchSummary.innerText = ""; const searchList = document.createElement("ul"); searchList.setAttribute("role", "list"); searchList.classList.add("search"); const out = document.getElementById("search-results"); Search.title = out.appendChild(searchText); Search.dots = Search.title.appendChild(document.createElement("span")); Search.status = out.appendChild(searchSummary); Search.output = out.appendChild(searchList); const searchProgress = document.getElementById("search-progress"); // Some themes don't use the search progress node if (searchProgress) { searchProgress.innerText = _("Preparing search..."); } Search.startPulse(); // index already loaded, the browser was quick! if (Search.hasIndex()) Search.query(query); else Search.deferQuery(query); }, _parseQuery: (query) => { // stem the search terms and add them to the correct list const stemmer = new Stemmer(); const searchTerms = new Set(); const excludedTerms = new Set(); const highlightTerms = new Set(); const objectTerms = new Set(splitQuery(query.toLowerCase().trim())); splitQuery(query.trim()).forEach((queryTerm) => { const queryTermLower = queryTerm.toLowerCase(); // maybe skip this "word" // stopwords array is from language_data.js if ( stopwords.indexOf(queryTermLower) !== -1 || queryTerm.match(/^\d+$/) ) return; // stem the word let word = stemmer.stemWord(queryTermLower); // select the correct list if (word[0] === "-") excludedTerms.add(word.substr(1)); else { searchTerms.add(word); highlightTerms.add(queryTermLower); } }); if (SPHINX_HIGHLIGHT_ENABLED) { // set in sphinx_highlight.js localStorage.setItem("sphinx_highlight_terms", [...highlightTerms].join(" ")) } // console.debug("SEARCH: searching for:"); // console.info("required: ", [...searchTerms]); // console.info("excluded: ", [...excludedTerms]); return [query, searchTerms, excludedTerms, highlightTerms, objectTerms]; }, /** * execute search (requires search index to be loaded) */ _performSearch: (query, searchTerms, excludedTerms, highlightTerms, objectTerms) => { const filenames = Search._index.filenames; const docNames = Search._index.docnames; const titles = Search._index.titles; const allTitles = Search._index.alltitles; const indexEntries = Search._index.indexentries; // Collect multiple result groups to be sorted separately and then ordered. // Each is an array of [docname, title, anchor, descr, score, filename, kind]. const normalResults = []; const nonMainIndexResults = []; _removeChildren(document.getElementById("search-progress")); const queryLower = query.toLowerCase().trim(); for (const [title, foundTitles] of Object.entries(allTitles)) { if (title.toLowerCase().trim().includes(queryLower) && (queryLower.length >= title.length / 2)) { for (const [file, id] of foundTitles) { const score = Math.round(Scorer.title * queryLower.length / title.length); const boost = titles[file] === title ? 1 : 0; // add a boost for document titles normalResults.push([ docNames[file], titles[file] !== title ? `${titles[file]} > ${title}` : title, id !== null ? "#" + id : "", null, score + boost, filenames[file], SearchResultKind.title, ]); } } } // search for explicit entries in index directives for (const [entry, foundEntries] of Object.entries(indexEntries)) { if (entry.includes(queryLower) && (queryLower.length >= entry.length / 2)) { for (const [file, id, isMain] of foundEntries) { const score = Math.round(100 * queryLower.length / entry.length); const result = [ docNames[file], titles[file], id ? "#" + id : "", null, score, filenames[file], SearchResultKind.index, ]; if (isMain) { normalResults.push(result); } else { nonMainIndexResults.push(result); } } } } // lookup as object objectTerms.forEach((term) => normalResults.push(...Search.performObjectSearch(term, objectTerms)) ); // lookup as search terms in fulltext normalResults.push(...Search.performTermsSearch(searchTerms, excludedTerms)); // let the scorer override scores with a custom scoring function if (Scorer.score) { normalResults.forEach((item) => (item[4] = Scorer.score(item))); nonMainIndexResults.forEach((item) => (item[4] = Scorer.score(item))); } // Sort each group of results by score and then alphabetically by name. normalResults.sort(_orderResultsByScoreThenName); nonMainIndexResults.sort(_orderResultsByScoreThenName); // Combine the result groups in (reverse) order. // Non-main index entries are typically arbitrary cross-references, // so display them after other results. let results = [...nonMainIndexResults, ...normalResults]; // remove duplicate search results // note the reversing of results, so that in the case of duplicates, the highest-scoring entry is kept let seen = new Set(); results = results.reverse().reduce((acc, result) => { let resultStr = result.slice(0, 4).concat([result[5]]).map(v => String(v)).join(','); if (!seen.has(resultStr)) { acc.push(result); seen.add(resultStr); } return acc; }, []); return results.reverse(); }, query: (query) => { const [searchQuery, searchTerms, excludedTerms, highlightTerms, objectTerms] = Search._parseQuery(query); const results = Search._performSearch(searchQuery, searchTerms, excludedTerms, highlightTerms, objectTerms); // for debugging //Search.lastresults = results.slice(); // a copy // console.info("search results:", Search.lastresults); // print the results _displayNextItem(results, results.length, searchTerms, highlightTerms); }, /** * search for object names */ performObjectSearch: (object, objectTerms) => { const filenames = Search._index.filenames; const docNames = Search._index.docnames; const objects = Search._index.objects; const objNames = Search._index.objnames; const titles = Search._index.titles; const results = []; const objectSearchCallback = (prefix, match) => { const name = match[4] const fullname = (prefix ? prefix + "." : "") + name; const fullnameLower = fullname.toLowerCase(); if (fullnameLower.indexOf(object) < 0) return; let score = 0; const parts = fullnameLower.split("."); // check for different match types: exact matches of full name or // "last name" (i.e. last dotted part) if (fullnameLower === object || parts.slice(-1)[0] === object) score += Scorer.objNameMatch; else if (parts.slice(-1)[0].indexOf(object) > -1) score += Scorer.objPartialMatch; // matches in last name const objName = objNames[match[1]][2]; const title = titles[match[0]]; // If more than one term searched for, we require other words to be // found in the name/title/description const otherTerms = new Set(objectTerms); otherTerms.delete(object); if (otherTerms.size > 0) { const haystack = `${prefix} ${name} ${objName} ${title}`.toLowerCase(); if ( [...otherTerms].some((otherTerm) => haystack.indexOf(otherTerm) < 0) ) return; } let anchor = match[3]; if (anchor === "") anchor = fullname; else if (anchor === "-") anchor = objNames[match[1]][1] + "-" + fullname; const descr = objName + _(", in ") + title; // add custom score for some objects according to scorer if (Scorer.objPrio.hasOwnProperty(match[2])) score += Scorer.objPrio[match[2]]; else score += Scorer.objPrioDefault; results.push([ docNames[match[0]], fullname, "#" + anchor, descr, score, filenames[match[0]], SearchResultKind.object, ]); }; Object.keys(objects).forEach((prefix) => objects[prefix].forEach((array) => objectSearchCallback(prefix, array) ) ); return results; }, /** * search for full-text terms in the index */ performTermsSearch: (searchTerms, excludedTerms) => { // prepare search const terms = Search._index.terms; const titleTerms = Search._index.titleterms; const filenames = Search._index.filenames; const docNames = Search._index.docnames; const titles = Search._index.titles; const scoreMap = new Map(); const fileMap = new Map(); // perform the search on the required terms searchTerms.forEach((word) => { const files = []; const arr = [ {files: terms[word], score: Scorer.term}, {files: titleTerms[word], score: Scorer.title}, ]; // add support for partial matches if (word.length > 2) { const escapedWord = _escapeRegExp(word); if (!terms.hasOwnProperty(word)) { Object.keys(terms).forEach((term) => { if (term.match(escapedWord)) arr.push({files: terms[term], score: Scorer.partialTerm}); }); } if (!titleTerms.hasOwnProperty(word)) { Object.keys(titleTerms).forEach((term) => { if (term.match(escapedWord)) arr.push({files: titleTerms[term], score: Scorer.partialTitle}); }); } } // no match but word was a required one if (arr.every((record) => record.files === undefined)) return; // found search word in contents arr.forEach((record) => { if (record.files === undefined) return; let recordFiles = record.files; if (recordFiles.length === undefined) recordFiles = [recordFiles]; files.push(...recordFiles); // set score for the word in each file recordFiles.forEach((file) => { if (!scoreMap.has(file)) scoreMap.set(file, {}); scoreMap.get(file)[word] = record.score; }); }); // create the mapping files.forEach((file) => { if (!fileMap.has(file)) fileMap.set(file, [word]); else if (fileMap.get(file).indexOf(word) === -1) fileMap.get(file).push(word); }); }); // now check if the files don't contain excluded terms const results = []; for (const [file, wordList] of fileMap) { // check if all requirements are matched // as search terms with length < 3 are discarded const filteredTermCount = [...searchTerms].filter( (term) => term.length > 2 ).length; if ( wordList.length !== searchTerms.size && wordList.length !== filteredTermCount ) continue; // ensure that none of the excluded terms is in the search result if ( [...excludedTerms].some( (term) => terms[term] === file || titleTerms[term] === file || (terms[term] || []).includes(file) || (titleTerms[term] || []).includes(file) ) ) break; // select one (max) score for the file. const score = Math.max(...wordList.map((w) => scoreMap.get(file)[w])); // add result to the result list results.push([ docNames[file], titles[file], "", null, score, filenames[file], SearchResultKind.text, ]); } return results; }, /** * helper function to return a node containing the * search summary for a given text. keywords is a list * of stemmed words. */ makeSearchSummary: (htmlText, keywords, anchor) => { const text = Search.htmlToText(htmlText, anchor); if (text === "") return null; const textLower = text.toLowerCase(); const actualStartPosition = [...keywords] .map((k) => textLower.indexOf(k.toLowerCase())) .filter((i) => i > -1) .slice(-1)[0]; const startWithContext = Math.max(actualStartPosition - 120, 0); const top = startWithContext === 0 ? "" : "..."; const tail = startWithContext + 240 < text.length ? "..." : ""; let summary = document.createElement("p"); summary.classList.add("context"); summary.textContent = top + text.substr(startWithContext, 240).trim() + tail; return summary; }, }; _ready(Search.init); ================================================ FILE: docs/WechatAPIClient/_static/skeleton.css ================================================ /* Some sane resets. */ html { height: 100%; } body { margin: 0; min-height: 100%; } /* All the flexbox magic! */ body, .sb-announcement, .sb-content, .sb-main, .sb-container, .sb-container__inner, .sb-article-container, .sb-footer-content, .sb-header, .sb-header-secondary, .sb-footer { display: flex; } /* These order things vertically */ body, .sb-main, .sb-article-container { flex-direction: column; } /* Put elements in the center */ .sb-header, .sb-header-secondary, .sb-container, .sb-content, .sb-footer, .sb-footer-content { justify-content: center; } /* Put elements at the ends */ .sb-article-container { justify-content: space-between; } /* These elements grow. */ .sb-main, .sb-content, .sb-container, article { flex-grow: 1; } /* Because padding making this wider is not fun */ article { box-sizing: border-box; } /* The announcements element should never be wider than the page. */ .sb-announcement { max-width: 100%; } .sb-sidebar-primary, .sb-sidebar-secondary { flex-shrink: 0; width: 17rem; } .sb-announcement__inner { justify-content: center; box-sizing: border-box; height: 3rem; overflow-x: auto; white-space: nowrap; } /* Sidebars, with checkbox-based toggle */ .sb-sidebar-primary, .sb-sidebar-secondary { position: fixed; height: 100%; top: 0; } .sb-sidebar-primary { left: -17rem; transition: left 250ms ease-in-out; } .sb-sidebar-secondary { right: -17rem; transition: right 250ms ease-in-out; } .sb-sidebar-toggle { display: none; } .sb-sidebar-overlay { position: fixed; top: 0; width: 0; height: 0; transition: width 0ms ease 250ms, height 0ms ease 250ms, opacity 250ms ease; opacity: 0; background-color: rgba(0, 0, 0, 0.54); } #sb-sidebar-toggle--primary:checked ~ .sb-sidebar-overlay[for="sb-sidebar-toggle--primary"], #sb-sidebar-toggle--secondary:checked ~ .sb-sidebar-overlay[for="sb-sidebar-toggle--secondary"] { width: 100%; height: 100%; opacity: 1; transition: width 0ms ease, height 0ms ease, opacity 250ms ease; } #sb-sidebar-toggle--primary:checked ~ .sb-container .sb-sidebar-primary { left: 0; } #sb-sidebar-toggle--secondary:checked ~ .sb-container .sb-sidebar-secondary { right: 0; } /* Full-width mode */ .drop-secondary-sidebar-for-full-width-content .hide-when-secondary-sidebar-shown { display: none !important; } .drop-secondary-sidebar-for-full-width-content .sb-sidebar-secondary { display: none !important; } /* Mobile views */ .sb-page-width { width: 100%; } .sb-article-container, .sb-footer-content__inner, .drop-secondary-sidebar-for-full-width-content .sb-article, .drop-secondary-sidebar-for-full-width-content .match-content-width { width: 100vw; } .sb-article, .match-content-width { padding: 0 1rem; box-sizing: border-box; } @media (min-width: 32rem) { .sb-article, .match-content-width { padding: 0 2rem; } } /* Tablet views */ @media (min-width: 42rem) { .sb-article-container { width: auto; } .sb-footer-content__inner, .drop-secondary-sidebar-for-full-width-content .sb-article, .drop-secondary-sidebar-for-full-width-content .match-content-width { width: 42rem; } .sb-article, .match-content-width { width: 42rem; } } @media (min-width: 46rem) { .sb-footer-content__inner, .drop-secondary-sidebar-for-full-width-content .sb-article, .drop-secondary-sidebar-for-full-width-content .match-content-width { width: 46rem; } .sb-article, .match-content-width { width: 46rem; } } @media (min-width: 50rem) { .sb-footer-content__inner, .drop-secondary-sidebar-for-full-width-content .sb-article, .drop-secondary-sidebar-for-full-width-content .match-content-width { width: 50rem; } .sb-article, .match-content-width { width: 50rem; } } /* Tablet views */ @media (min-width: 59rem) { .sb-sidebar-secondary { position: static; } .hide-when-secondary-sidebar-shown { display: none !important; } .sb-footer-content__inner, .drop-secondary-sidebar-for-full-width-content .sb-article, .drop-secondary-sidebar-for-full-width-content .match-content-width { width: 59rem; } .sb-article, .match-content-width { width: 42rem; } } @media (min-width: 63rem) { .sb-footer-content__inner, .drop-secondary-sidebar-for-full-width-content .sb-article, .drop-secondary-sidebar-for-full-width-content .match-content-width { width: 63rem; } .sb-article, .match-content-width { width: 46rem; } } @media (min-width: 67rem) { .sb-footer-content__inner, .drop-secondary-sidebar-for-full-width-content .sb-article, .drop-secondary-sidebar-for-full-width-content .match-content-width { width: 67rem; } .sb-article, .match-content-width { width: 50rem; } } /* Desktop views */ @media (min-width: 76rem) { .sb-sidebar-primary { position: static; } .hide-when-primary-sidebar-shown { display: none !important; } .sb-footer-content__inner, .drop-secondary-sidebar-for-full-width-content .sb-article, .drop-secondary-sidebar-for-full-width-content .match-content-width { width: 59rem; } .sb-article, .match-content-width { width: 42rem; } } /* Full desktop views */ @media (min-width: 80rem) { .sb-article, .match-content-width { width: 46rem; } .sb-footer-content__inner, .drop-secondary-sidebar-for-full-width-content .sb-article, .drop-secondary-sidebar-for-full-width-content .match-content-width { width: 63rem; } } @media (min-width: 84rem) { .sb-article, .match-content-width { width: 50rem; } .sb-footer-content__inner, .drop-secondary-sidebar-for-full-width-content .sb-article, .drop-secondary-sidebar-for-full-width-content .match-content-width { width: 67rem; } } @media (min-width: 88rem) { .sb-footer-content__inner, .drop-secondary-sidebar-for-full-width-content .sb-article, .drop-secondary-sidebar-for-full-width-content .match-content-width { width: 67rem; } .sb-page-width { width: 88rem; } } ================================================ FILE: docs/WechatAPIClient/_static/sphinx_highlight.js ================================================ /* Highlighting utilities for Sphinx HTML documentation. */ "use strict"; const SPHINX_HIGHLIGHT_ENABLED = true /** * highlight a given string on a node by wrapping it in * span elements with the given class name. */ const _highlight = (node, addItems, text, className) => { if (node.nodeType === Node.TEXT_NODE) { const val = node.nodeValue; const parent = node.parentNode; const pos = val.toLowerCase().indexOf(text); if ( pos >= 0 && !parent.classList.contains(className) && !parent.classList.contains("nohighlight") ) { let span; const closestNode = parent.closest("body, svg, foreignObject"); const isInSVG = closestNode && closestNode.matches("svg"); if (isInSVG) { span = document.createElementNS("http://www.w3.org/2000/svg", "tspan"); } else { span = document.createElement("span"); span.classList.add(className); } span.appendChild(document.createTextNode(val.substr(pos, text.length))); const rest = document.createTextNode(val.substr(pos + text.length)); parent.insertBefore( span, parent.insertBefore( rest, node.nextSibling ) ); node.nodeValue = val.substr(0, pos); /* There may be more occurrences of search term in this node. So call this * function recursively on the remaining fragment. */ _highlight(rest, addItems, text, className); if (isInSVG) { const rect = document.createElementNS( "http://www.w3.org/2000/svg", "rect" ); const bbox = parent.getBBox(); rect.x.baseVal.value = bbox.x; rect.y.baseVal.value = bbox.y; rect.width.baseVal.value = bbox.width; rect.height.baseVal.value = bbox.height; rect.setAttribute("class", className); addItems.push({parent: parent, target: rect}); } } } else if (node.matches && !node.matches("button, select, textarea")) { node.childNodes.forEach((el) => _highlight(el, addItems, text, className)); } }; const _highlightText = (thisNode, text, className) => { let addItems = []; _highlight(thisNode, addItems, text, className); addItems.forEach((obj) => obj.parent.insertAdjacentElement("beforebegin", obj.target) ); }; /** * Small JavaScript module for the documentation. */ const SphinxHighlight = { /** * highlight the search words provided in localstorage in the text */ highlightSearchWords: () => { if (!SPHINX_HIGHLIGHT_ENABLED) return; // bail if no highlight // get and clear terms from localstorage const url = new URL(window.location); const highlight = localStorage.getItem("sphinx_highlight_terms") || url.searchParams.get("highlight") || ""; localStorage.removeItem("sphinx_highlight_terms") url.searchParams.delete("highlight"); window.history.replaceState({}, "", url); // get individual terms from highlight string const terms = highlight.toLowerCase().split(/\s+/).filter(x => x); if (terms.length === 0) return; // nothing to do // There should never be more than one element matching "div.body" const divBody = document.querySelectorAll("div.body"); const body = divBody.length ? divBody[0] : document.querySelector("body"); window.setTimeout(() => { terms.forEach((term) => _highlightText(body, term, "highlighted")); }, 10); const searchBox = document.getElementById("searchbox"); if (searchBox === null) return; searchBox.appendChild( document .createRange() .createContextualFragment( '" ) ); }, /** * helper function to hide the search marks again */ hideSearchWords: () => { document .querySelectorAll("#searchbox .highlight-link") .forEach((el) => el.remove()); document .querySelectorAll("span.highlighted") .forEach((el) => el.classList.remove("highlighted")); localStorage.removeItem("sphinx_highlight_terms") }, initEscapeListener: () => { // only install a listener if it is really needed if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) return; document.addEventListener("keydown", (event) => { // bail for input elements if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) return; // bail with special keys if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return; if (DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS && (event.key === "Escape")) { SphinxHighlight.hideSearchWords(); event.preventDefault(); } }); }, }; _ready(() => { /* Do not call highlightSearchWords() when we are on the search page. * It will highlight words from the *previous* search query. */ if (typeof Search === "undefined") SphinxHighlight.highlightSearchWords(); SphinxHighlight.initEscapeListener(); }); ================================================ FILE: docs/WechatAPIClient/_static/styles/furo-extensions.css ================================================ #furo-sidebar-ad-placement{padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)}#furo-sidebar-ad-placement .ethical-sidebar{background:var(--color-background-secondary);border:none;box-shadow:none}#furo-sidebar-ad-placement .ethical-sidebar:hover{background:var(--color-background-hover)}#furo-sidebar-ad-placement .ethical-sidebar a{color:var(--color-foreground-primary)}#furo-sidebar-ad-placement .ethical-callout a{color:var(--color-foreground-secondary)!important}#furo-readthedocs-versions{background:transparent;display:block;position:static;width:100%}#furo-readthedocs-versions .rst-versions{background:#1a1c1e}#furo-readthedocs-versions .rst-current-version{background:var(--color-sidebar-item-background);cursor:unset}#furo-readthedocs-versions .rst-current-version:hover{background:var(--color-sidebar-item-background)}#furo-readthedocs-versions .rst-current-version .fa-book{color:var(--color-foreground-primary)}#furo-readthedocs-versions>.rst-other-versions{padding:0}#furo-readthedocs-versions>.rst-other-versions small{opacity:1}#furo-readthedocs-versions .injected .rst-versions{position:unset}#furo-readthedocs-versions:focus-within,#furo-readthedocs-versions:hover{box-shadow:0 0 0 1px var(--color-sidebar-background-border)}#furo-readthedocs-versions:focus-within .rst-current-version,#furo-readthedocs-versions:hover .rst-current-version{background:#1a1c1e;font-size:inherit;height:auto;line-height:inherit;padding:12px;text-align:right}#furo-readthedocs-versions:focus-within .rst-current-version .fa-book,#furo-readthedocs-versions:hover .rst-current-version .fa-book{color:#fff;float:left}#furo-readthedocs-versions:focus-within .fa-caret-down,#furo-readthedocs-versions:hover .fa-caret-down{display:none}#furo-readthedocs-versions:focus-within .injected,#furo-readthedocs-versions:focus-within .rst-current-version,#furo-readthedocs-versions:focus-within .rst-other-versions,#furo-readthedocs-versions:hover .injected,#furo-readthedocs-versions:hover .rst-current-version,#furo-readthedocs-versions:hover .rst-other-versions{display:block}#furo-readthedocs-versions:focus-within>.rst-current-version,#furo-readthedocs-versions:hover>.rst-current-version{display:none}.highlight:hover button.copybtn{color:var(--color-code-foreground)}.highlight button.copybtn{align-items:center;background-color:var(--color-code-background);border:none;color:var(--color-background-item);cursor:pointer;height:1.25em;right:.5rem;top:.625rem;transition:color .3s,opacity .3s;width:1.25em}.highlight button.copybtn:hover{background-color:var(--color-code-background);color:var(--color-brand-content)}.highlight button.copybtn:after{background-color:transparent;color:var(--color-code-foreground);display:none}.highlight button.copybtn.success{color:#22863a;transition:color 0ms}.highlight button.copybtn.success:after{display:block}.highlight button.copybtn svg{padding:0}body{--sd-color-primary:var(--color-brand-primary);--sd-color-primary-highlight:var(--color-brand-content);--sd-color-primary-text:var(--color-background-primary);--sd-color-shadow:rgba(0,0,0,.05);--sd-color-card-border:var(--color-card-border);--sd-color-card-border-hover:var(--color-brand-content);--sd-color-card-background:var(--color-card-background);--sd-color-card-text:var(--color-foreground-primary);--sd-color-card-header:var(--color-card-marginals-background);--sd-color-card-footer:var(--color-card-marginals-background);--sd-color-tabs-label-active:var(--color-brand-content);--sd-color-tabs-label-hover:var(--color-foreground-muted);--sd-color-tabs-label-inactive:var(--color-foreground-muted);--sd-color-tabs-underline-active:var(--color-brand-content);--sd-color-tabs-underline-hover:var(--color-foreground-border);--sd-color-tabs-underline-inactive:var(--color-background-border);--sd-color-tabs-overline:var(--color-background-border);--sd-color-tabs-underline:var(--color-background-border)}.sd-tab-content{box-shadow:0 -2px var(--sd-color-tabs-overline),0 1px var(--sd-color-tabs-underline)}.sd-card{box-shadow:0 .1rem .25rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)}.sd-shadow-sm{box-shadow:0 .1rem .25rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-shadow-md{box-shadow:0 .3rem .75rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-shadow-lg{box-shadow:0 .6rem 1.5rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-card-hover:hover{transform:none}.sd-cards-carousel{gap:.25rem;padding:.25rem}body{--tabs--label-text:var(--color-foreground-muted);--tabs--label-text--hover:var(--color-foreground-muted);--tabs--label-text--active:var(--color-brand-content);--tabs--label-text--active--hover:var(--color-brand-content);--tabs--label-background:transparent;--tabs--label-background--hover:transparent;--tabs--label-background--active:transparent;--tabs--label-background--active--hover:transparent;--tabs--padding-x:0.25em;--tabs--margin-x:1em;--tabs--border:var(--color-background-border);--tabs--label-border:transparent;--tabs--label-border--hover:var(--color-foreground-muted);--tabs--label-border--active:var(--color-brand-content);--tabs--label-border--active--hover:var(--color-brand-content)}[role=main] .container{max-width:none;padding-left:0;padding-right:0}.shadow.docutils{border:none;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1)!important}.sphinx-bs .card{background-color:var(--color-background-secondary);color:var(--color-foreground)} /*# sourceMappingURL=furo-extensions.css.map*/ ================================================ FILE: docs/WechatAPIClient/_static/styles/furo.css ================================================ /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}[hidden],template{display:none}@media print{.content-icon-container,.headerlink,.mobile-header,.related-pages{display:none!important}.highlight{border:.1pt solid var(--color-foreground-border)}a,blockquote,dl,ol,p,pre,table,ul{page-break-inside:avoid}caption,figure,h1,h2,h3,h4,h5,h6,img{page-break-after:avoid;page-break-inside:avoid}dl,ol,ul{page-break-before:avoid}}.visually-hidden{height:1px!important;margin:-1px!important;overflow:hidden!important;padding:0!important;position:absolute!important;width:1px!important;clip:rect(0,0,0,0)!important;background:var(--color-background-primary);border:0!important;color:var(--color-foreground-primary);white-space:nowrap!important}:-moz-focusring{outline:auto}body{--font-stack:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;--font-stack--monospace:"SFMono-Regular",Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace;--font-stack--headings:var(--font-stack);--font-size--normal:100%;--font-size--small:87.5%;--font-size--small--2:81.25%;--font-size--small--3:75%;--font-size--small--4:62.5%;--sidebar-caption-font-size:var(--font-size--small--2);--sidebar-item-font-size:var(--font-size--small);--sidebar-search-input-font-size:var(--font-size--small);--toc-font-size:var(--font-size--small--3);--toc-font-size--mobile:var(--font-size--normal);--toc-title-font-size:var(--font-size--small--4);--admonition-font-size:0.8125rem;--admonition-title-font-size:0.8125rem;--code-font-size:var(--font-size--small--2);--api-font-size:var(--font-size--small);--header-height:calc(var(--sidebar-item-line-height) + var(--sidebar-item-spacing-vertical)*4);--header-padding:0.5rem;--sidebar-tree-space-above:1.5rem;--sidebar-caption-space-above:1rem;--sidebar-item-line-height:1rem;--sidebar-item-spacing-vertical:0.5rem;--sidebar-item-spacing-horizontal:1rem;--sidebar-item-height:calc(var(--sidebar-item-line-height) + var(--sidebar-item-spacing-vertical)*2);--sidebar-expander-width:var(--sidebar-item-height);--sidebar-search-space-above:0.5rem;--sidebar-search-input-spacing-vertical:0.5rem;--sidebar-search-input-spacing-horizontal:0.5rem;--sidebar-search-input-height:1rem;--sidebar-search-icon-size:var(--sidebar-search-input-height);--toc-title-padding:0.25rem 0;--toc-spacing-vertical:1.5rem;--toc-spacing-horizontal:1.5rem;--toc-item-spacing-vertical:0.4rem;--toc-item-spacing-horizontal:1rem;--icon-search:url('data:image/svg+xml;charset=utf-8,');--icon-pencil:url('data:image/svg+xml;charset=utf-8,');--icon-abstract:url('data:image/svg+xml;charset=utf-8,');--icon-info:url('data:image/svg+xml;charset=utf-8,');--icon-flame:url('data:image/svg+xml;charset=utf-8,');--icon-question:url('data:image/svg+xml;charset=utf-8,');--icon-warning:url('data:image/svg+xml;charset=utf-8,');--icon-failure:url('data:image/svg+xml;charset=utf-8,');--icon-spark:url('data:image/svg+xml;charset=utf-8,');--color-admonition-title--caution:#ff9100;--color-admonition-title-background--caution:rgba(255,145,0,.2);--color-admonition-title--warning:#ff9100;--color-admonition-title-background--warning:rgba(255,145,0,.2);--color-admonition-title--danger:#ff5252;--color-admonition-title-background--danger:rgba(255,82,82,.2);--color-admonition-title--attention:#ff5252;--color-admonition-title-background--attention:rgba(255,82,82,.2);--color-admonition-title--error:#ff5252;--color-admonition-title-background--error:rgba(255,82,82,.2);--color-admonition-title--hint:#00c852;--color-admonition-title-background--hint:rgba(0,200,82,.2);--color-admonition-title--tip:#00c852;--color-admonition-title-background--tip:rgba(0,200,82,.2);--color-admonition-title--important:#00bfa5;--color-admonition-title-background--important:rgba(0,191,165,.2);--color-admonition-title--note:#00b0ff;--color-admonition-title-background--note:rgba(0,176,255,.2);--color-admonition-title--seealso:#448aff;--color-admonition-title-background--seealso:rgba(68,138,255,.2);--color-admonition-title--admonition-todo:grey;--color-admonition-title-background--admonition-todo:hsla(0,0%,50%,.2);--color-admonition-title:#651fff;--color-admonition-title-background:rgba(101,31,255,.2);--icon-admonition-default:var(--icon-abstract);--color-topic-title:#14b8a6;--color-topic-title-background:rgba(20,184,166,.2);--icon-topic-default:var(--icon-pencil);--color-problematic:#b30000;--color-foreground-primary:#000;--color-foreground-secondary:#5a5c63;--color-foreground-muted:#6b6f76;--color-foreground-border:#878787;--color-background-primary:#fff;--color-background-secondary:#f8f9fb;--color-background-hover:#efeff4;--color-background-hover--transparent:#efeff400;--color-background-border:#eeebee;--color-background-item:#ccc;--color-announcement-background:#000000dd;--color-announcement-text:#eeebee;--color-brand-primary:#0a4bff;--color-brand-content:#2757dd;--color-brand-visited:#872ee0;--color-api-background:var(--color-background-hover--transparent);--color-api-background-hover:var(--color-background-hover);--color-api-overall:var(--color-foreground-secondary);--color-api-name:var(--color-problematic);--color-api-pre-name:var(--color-problematic);--color-api-paren:var(--color-foreground-secondary);--color-api-keyword:var(--color-foreground-primary);--color-api-added:#21632c;--color-api-added-border:#38a84d;--color-api-changed:#046172;--color-api-changed-border:#06a1bc;--color-api-deprecated:#605706;--color-api-deprecated-border:#f0d90f;--color-api-removed:#b30000;--color-api-removed-border:#ff5c5c;--color-highlight-on-target:#ffc;--color-inline-code-background:var(--color-background-secondary);--color-highlighted-background:#def;--color-highlighted-text:var(--color-foreground-primary);--color-guilabel-background:#ddeeff80;--color-guilabel-border:#bedaf580;--color-guilabel-text:var(--color-foreground-primary);--color-admonition-background:transparent;--color-table-header-background:var(--color-background-secondary);--color-table-border:var(--color-background-border);--color-card-border:var(--color-background-secondary);--color-card-background:transparent;--color-card-marginals-background:var(--color-background-secondary);--color-header-background:var(--color-background-primary);--color-header-border:var(--color-background-border);--color-header-text:var(--color-foreground-primary);--color-sidebar-background:var(--color-background-secondary);--color-sidebar-background-border:var(--color-background-border);--color-sidebar-brand-text:var(--color-foreground-primary);--color-sidebar-caption-text:var(--color-foreground-muted);--color-sidebar-link-text:var(--color-foreground-secondary);--color-sidebar-link-text--top-level:var(--color-brand-primary);--color-sidebar-item-background:var(--color-sidebar-background);--color-sidebar-item-background--current:var( --color-sidebar-item-background );--color-sidebar-item-background--hover:linear-gradient(90deg,var(--color-background-hover--transparent) 0%,var(--color-background-hover) var(--sidebar-item-spacing-horizontal),var(--color-background-hover) 100%);--color-sidebar-item-expander-background:transparent;--color-sidebar-item-expander-background--hover:var( --color-background-hover );--color-sidebar-search-text:var(--color-foreground-primary);--color-sidebar-search-background:var(--color-background-secondary);--color-sidebar-search-background--focus:var(--color-background-primary);--color-sidebar-search-border:var(--color-background-border);--color-sidebar-search-icon:var(--color-foreground-muted);--color-toc-background:var(--color-background-primary);--color-toc-title-text:var(--color-foreground-muted);--color-toc-item-text:var(--color-foreground-secondary);--color-toc-item-text--hover:var(--color-foreground-primary);--color-toc-item-text--active:var(--color-brand-primary);--color-content-foreground:var(--color-foreground-primary);--color-content-background:transparent;--color-link:var(--color-brand-content);--color-link-underline:var(--color-background-border);--color-link--hover:var(--color-brand-content);--color-link-underline--hover:var(--color-foreground-border);--color-link--visited:var(--color-brand-visited);--color-link-underline--visited:var(--color-background-border);--color-link--visited--hover:var(--color-brand-visited);--color-link-underline--visited--hover:var(--color-foreground-border)}.only-light{display:block!important}html body .only-dark{display:none!important}@media not print{body[data-theme=dark]{--color-problematic:#ee5151;--color-foreground-primary:#cfd0d0;--color-foreground-secondary:#9ca0a5;--color-foreground-muted:#81868d;--color-foreground-border:#666;--color-background-primary:#131416;--color-background-secondary:#1a1c1e;--color-background-hover:#1e2124;--color-background-hover--transparent:#1e212400;--color-background-border:#303335;--color-background-item:#444;--color-announcement-background:#000000dd;--color-announcement-text:#eeebee;--color-brand-primary:#3d94ff;--color-brand-content:#5ca5ff;--color-brand-visited:#b27aeb;--color-highlighted-background:#083563;--color-guilabel-background:#08356380;--color-guilabel-border:#13395f80;--color-api-keyword:var(--color-foreground-secondary);--color-highlight-on-target:#330;--color-api-added:#3db854;--color-api-added-border:#267334;--color-api-changed:#09b0ce;--color-api-changed-border:#056d80;--color-api-deprecated:#b1a10b;--color-api-deprecated-border:#6e6407;--color-api-removed:#ff7575;--color-api-removed-border:#b03b3b;--color-admonition-background:#18181a;--color-card-border:var(--color-background-secondary);--color-card-background:#18181a;--color-card-marginals-background:var(--color-background-hover)}html body[data-theme=dark] .only-light{display:none!important}body[data-theme=dark] .only-dark{display:block!important}@media(prefers-color-scheme:dark){body:not([data-theme=light]){--color-problematic:#ee5151;--color-foreground-primary:#cfd0d0;--color-foreground-secondary:#9ca0a5;--color-foreground-muted:#81868d;--color-foreground-border:#666;--color-background-primary:#131416;--color-background-secondary:#1a1c1e;--color-background-hover:#1e2124;--color-background-hover--transparent:#1e212400;--color-background-border:#303335;--color-background-item:#444;--color-announcement-background:#000000dd;--color-announcement-text:#eeebee;--color-brand-primary:#3d94ff;--color-brand-content:#5ca5ff;--color-brand-visited:#b27aeb;--color-highlighted-background:#083563;--color-guilabel-background:#08356380;--color-guilabel-border:#13395f80;--color-api-keyword:var(--color-foreground-secondary);--color-highlight-on-target:#330;--color-api-added:#3db854;--color-api-added-border:#267334;--color-api-changed:#09b0ce;--color-api-changed-border:#056d80;--color-api-deprecated:#b1a10b;--color-api-deprecated-border:#6e6407;--color-api-removed:#ff7575;--color-api-removed-border:#b03b3b;--color-admonition-background:#18181a;--color-card-border:var(--color-background-secondary);--color-card-background:#18181a;--color-card-marginals-background:var(--color-background-hover)}html body:not([data-theme=light]) .only-light{display:none!important}body:not([data-theme=light]) .only-dark{display:block!important}}}body[data-theme=auto] .theme-toggle svg.theme-icon-when-auto-light{display:block}@media(prefers-color-scheme:dark){body[data-theme=auto] .theme-toggle svg.theme-icon-when-auto-dark{display:block}body[data-theme=auto] .theme-toggle svg.theme-icon-when-auto-light{display:none}}body[data-theme=dark] .theme-toggle svg.theme-icon-when-dark,body[data-theme=light] .theme-toggle svg.theme-icon-when-light{display:block}body{font-family:var(--font-stack)}code,kbd,pre,samp{font-family:var(--font-stack--monospace)}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}article{line-height:1.5}h1,h2,h3,h4,h5,h6{border-radius:.5rem;font-family:var(--font-stack--headings);font-weight:700;line-height:1.25;margin:.5rem -.5rem;padding-left:.5rem;padding-right:.5rem}h1+p,h2+p,h3+p,h4+p,h5+p,h6+p{margin-top:0}h1{font-size:2.5em;margin-bottom:1rem}h1,h2{margin-top:1.75rem}h2{font-size:2em}h3{font-size:1.5em}h4{font-size:1.25em}h5{font-size:1.125em}h6{font-size:1em}small{font-size:80%;opacity:75%}p{margin-bottom:.75rem;margin-top:.5rem}hr.docutils{background-color:var(--color-background-border);border:0;height:1px;margin:2rem 0;padding:0}.centered{text-align:center}a{color:var(--color-link);text-decoration:underline;text-decoration-color:var(--color-link-underline)}a:visited{color:var(--color-link--visited);text-decoration-color:var(--color-link-underline--visited)}a:visited:hover{color:var(--color-link--visited--hover);text-decoration-color:var(--color-link-underline--visited--hover)}a:hover{color:var(--color-link--hover);text-decoration-color:var(--color-link-underline--hover)}a.muted-link{color:inherit}a.muted-link:hover{color:var(--color-link--hover);text-decoration-color:var(--color-link-underline--hover)}a.muted-link:hover:visited{color:var(--color-link--visited--hover);text-decoration-color:var(--color-link-underline--visited--hover)}html{overflow-x:hidden;overflow-y:scroll;scroll-behavior:smooth}.sidebar-scroll,.toc-scroll,article[role=main] *{scrollbar-color:var(--color-foreground-border) transparent;scrollbar-width:thin}.sidebar-scroll::-webkit-scrollbar,.toc-scroll::-webkit-scrollbar,article[role=main] ::-webkit-scrollbar{height:.25rem;width:.25rem}.sidebar-scroll::-webkit-scrollbar-thumb,.toc-scroll::-webkit-scrollbar-thumb,article[role=main] ::-webkit-scrollbar-thumb{background-color:var(--color-foreground-border);border-radius:.125rem}body,html{height:100%}.skip-to-content,body,html{background:var(--color-background-primary);color:var(--color-foreground-primary)}.skip-to-content{border-radius:1rem;left:.25rem;padding:1rem;position:fixed;top:.25rem;transform:translateY(-200%);transition:transform .3s ease-in-out;z-index:40}.skip-to-content:focus-within{transform:translateY(0)}article{background:var(--color-content-background);color:var(--color-content-foreground);overflow-wrap:break-word}.page{display:flex;min-height:100%}.mobile-header{background-color:var(--color-header-background);border-bottom:1px solid var(--color-header-border);color:var(--color-header-text);display:none;height:var(--header-height);width:100%;z-index:10}.mobile-header.scrolled{border-bottom:none;box-shadow:0 0 .2rem rgba(0,0,0,.1),0 .2rem .4rem rgba(0,0,0,.2)}.mobile-header .header-center a{color:var(--color-header-text);text-decoration:none}.main{display:flex;flex:1}.sidebar-drawer{background:var(--color-sidebar-background);border-right:1px solid var(--color-sidebar-background-border);box-sizing:border-box;display:flex;justify-content:flex-end;min-width:15em;width:calc(50% - 26em)}.sidebar-container,.toc-drawer{box-sizing:border-box;width:15em}.toc-drawer{background:var(--color-toc-background);padding-right:1rem}.sidebar-sticky,.toc-sticky{display:flex;flex-direction:column;height:min(100%,100vh);height:100vh;position:sticky;top:0}.sidebar-scroll,.toc-scroll{flex-grow:1;flex-shrink:1;overflow:auto;scroll-behavior:smooth}.content{display:flex;flex-direction:column;justify-content:space-between;padding:0 3em;width:46em}.icon{display:inline-block;height:1rem;width:1rem}.icon svg{height:100%;width:100%}.announcement{align-items:center;background-color:var(--color-announcement-background);color:var(--color-announcement-text);display:flex;height:var(--header-height);overflow-x:auto}.announcement+.page{min-height:calc(100% - var(--header-height))}.announcement-content{box-sizing:border-box;min-width:100%;padding:.5rem;text-align:center;white-space:nowrap}.announcement-content a{color:var(--color-announcement-text);text-decoration-color:var(--color-announcement-text)}.announcement-content a:hover{color:var(--color-announcement-text);text-decoration-color:var(--color-link--hover)}.no-js .theme-toggle-container{display:none}.theme-toggle-container{display:flex}.theme-toggle{background:transparent;border:none;cursor:pointer;display:flex;padding:0}.theme-toggle svg{color:var(--color-foreground-primary);display:none;height:1.25rem;width:1.25rem}.theme-toggle-header{align-items:center;display:flex;justify-content:center}.nav-overlay-icon,.toc-overlay-icon{cursor:pointer;display:none}.nav-overlay-icon .icon,.toc-overlay-icon .icon{color:var(--color-foreground-secondary);height:1.5rem;width:1.5rem}.nav-overlay-icon,.toc-header-icon{align-items:center;justify-content:center}.toc-content-icon{height:1.5rem;width:1.5rem}.content-icon-container{display:flex;float:right;gap:.5rem;margin-bottom:1rem;margin-left:1rem;margin-top:1.5rem}.content-icon-container .edit-this-page svg,.content-icon-container .view-this-page svg{color:inherit;height:1.25rem;width:1.25rem}.sidebar-toggle{display:none;position:absolute}.sidebar-toggle[name=__toc]{left:20px}.sidebar-toggle:checked{left:40px}.overlay{background-color:rgba(0,0,0,.54);height:0;opacity:0;position:fixed;top:0;transition:width 0ms,height 0ms,opacity .25s ease-out;width:0}.sidebar-overlay{z-index:20}.toc-overlay{z-index:40}.sidebar-drawer{transition:left .25s ease-in-out;z-index:30}.toc-drawer{transition:right .25s ease-in-out;z-index:50}#__navigation:checked~.sidebar-overlay{height:100%;opacity:1;width:100%}#__navigation:checked~.page .sidebar-drawer{left:0;top:0}#__toc:checked~.toc-overlay{height:100%;opacity:1;width:100%}#__toc:checked~.page .toc-drawer{right:0;top:0}.back-to-top{background:var(--color-background-primary);border-radius:1rem;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 1px 0 hsla(220,9%,46%,.502);display:none;font-size:.8125rem;left:0;margin-left:50%;padding:.5rem .75rem .5rem .5rem;position:fixed;text-decoration:none;top:1rem;transform:translateX(-50%);z-index:10}.back-to-top svg{height:1rem;width:1rem;fill:currentColor;display:inline-block}.back-to-top span{margin-left:.25rem}.show-back-to-top .back-to-top{align-items:center;display:flex}@media(min-width:97em){html{font-size:110%}}@media(max-width:82em){.toc-content-icon{display:flex}.toc-drawer{border-left:1px solid var(--color-background-muted);height:100vh;position:fixed;right:-15em;top:0}.toc-tree{border-left:none;font-size:var(--toc-font-size--mobile)}.sidebar-drawer{width:calc(50% - 18.5em)}}@media(max-width:67em){.content{margin-left:auto;margin-right:auto;padding:0 1em}}@media(max-width:63em){.nav-overlay-icon{display:flex}.sidebar-drawer{height:100vh;left:-15em;position:fixed;top:0;width:15em}.theme-toggle-header,.toc-header-icon{display:flex}.theme-toggle-content,.toc-content-icon{display:none}.mobile-header{align-items:center;display:flex;justify-content:space-between;position:sticky;top:0}.mobile-header .header-left,.mobile-header .header-right{display:flex;height:var(--header-height);padding:0 var(--header-padding)}.mobile-header .header-left label,.mobile-header .header-right label{height:100%;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:100%}.nav-overlay-icon .icon,.theme-toggle svg{height:1.5rem;width:1.5rem}:target{scroll-margin-top:calc(var(--header-height) + 2.5rem)}.back-to-top{top:calc(var(--header-height) + .5rem)}.page{flex-direction:column;justify-content:center}}@media(max-width:48em){.content{overflow-x:auto;width:100%}}@media(max-width:46em){article[role=main] aside.sidebar{float:none;margin:1rem 0;width:100%}}.admonition,.topic{background:var(--color-admonition-background);border-radius:.2rem;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1);font-size:var(--admonition-font-size);margin:1rem auto;overflow:hidden;padding:0 .5rem .5rem;page-break-inside:avoid}.admonition>:nth-child(2),.topic>:nth-child(2){margin-top:0}.admonition>:last-child,.topic>:last-child{margin-bottom:0}.admonition p.admonition-title,p.topic-title{font-size:var(--admonition-title-font-size);font-weight:500;line-height:1.3;margin:0 -.5rem .5rem;padding:.4rem .5rem .4rem 2rem;position:relative}.admonition p.admonition-title:before,p.topic-title:before{content:"";height:1rem;left:.5rem;position:absolute;width:1rem}p.admonition-title{background-color:var(--color-admonition-title-background)}p.admonition-title:before{background-color:var(--color-admonition-title);-webkit-mask-image:var(--icon-admonition-default);mask-image:var(--icon-admonition-default);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}p.topic-title{background-color:var(--color-topic-title-background)}p.topic-title:before{background-color:var(--color-topic-title);-webkit-mask-image:var(--icon-topic-default);mask-image:var(--icon-topic-default);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.admonition{border-left:.2rem solid var(--color-admonition-title)}.admonition.caution{border-left-color:var(--color-admonition-title--caution)}.admonition.caution>.admonition-title{background-color:var(--color-admonition-title-background--caution)}.admonition.caution>.admonition-title:before{background-color:var(--color-admonition-title--caution);-webkit-mask-image:var(--icon-spark);mask-image:var(--icon-spark)}.admonition.warning{border-left-color:var(--color-admonition-title--warning)}.admonition.warning>.admonition-title{background-color:var(--color-admonition-title-background--warning)}.admonition.warning>.admonition-title:before{background-color:var(--color-admonition-title--warning);-webkit-mask-image:var(--icon-warning);mask-image:var(--icon-warning)}.admonition.danger{border-left-color:var(--color-admonition-title--danger)}.admonition.danger>.admonition-title{background-color:var(--color-admonition-title-background--danger)}.admonition.danger>.admonition-title:before{background-color:var(--color-admonition-title--danger);-webkit-mask-image:var(--icon-spark);mask-image:var(--icon-spark)}.admonition.attention{border-left-color:var(--color-admonition-title--attention)}.admonition.attention>.admonition-title{background-color:var(--color-admonition-title-background--attention)}.admonition.attention>.admonition-title:before{background-color:var(--color-admonition-title--attention);-webkit-mask-image:var(--icon-warning);mask-image:var(--icon-warning)}.admonition.error{border-left-color:var(--color-admonition-title--error)}.admonition.error>.admonition-title{background-color:var(--color-admonition-title-background--error)}.admonition.error>.admonition-title:before{background-color:var(--color-admonition-title--error);-webkit-mask-image:var(--icon-failure);mask-image:var(--icon-failure)}.admonition.hint{border-left-color:var(--color-admonition-title--hint)}.admonition.hint>.admonition-title{background-color:var(--color-admonition-title-background--hint)}.admonition.hint>.admonition-title:before{background-color:var(--color-admonition-title--hint);-webkit-mask-image:var(--icon-question);mask-image:var(--icon-question)}.admonition.tip{border-left-color:var(--color-admonition-title--tip)}.admonition.tip>.admonition-title{background-color:var(--color-admonition-title-background--tip)}.admonition.tip>.admonition-title:before{background-color:var(--color-admonition-title--tip);-webkit-mask-image:var(--icon-info);mask-image:var(--icon-info)}.admonition.important{border-left-color:var(--color-admonition-title--important)}.admonition.important>.admonition-title{background-color:var(--color-admonition-title-background--important)}.admonition.important>.admonition-title:before{background-color:var(--color-admonition-title--important);-webkit-mask-image:var(--icon-flame);mask-image:var(--icon-flame)}.admonition.note{border-left-color:var(--color-admonition-title--note)}.admonition.note>.admonition-title{background-color:var(--color-admonition-title-background--note)}.admonition.note>.admonition-title:before{background-color:var(--color-admonition-title--note);-webkit-mask-image:var(--icon-pencil);mask-image:var(--icon-pencil)}.admonition.seealso{border-left-color:var(--color-admonition-title--seealso)}.admonition.seealso>.admonition-title{background-color:var(--color-admonition-title-background--seealso)}.admonition.seealso>.admonition-title:before{background-color:var(--color-admonition-title--seealso);-webkit-mask-image:var(--icon-info);mask-image:var(--icon-info)}.admonition.admonition-todo{border-left-color:var(--color-admonition-title--admonition-todo)}.admonition.admonition-todo>.admonition-title{background-color:var(--color-admonition-title-background--admonition-todo)}.admonition.admonition-todo>.admonition-title:before{background-color:var(--color-admonition-title--admonition-todo);-webkit-mask-image:var(--icon-pencil);mask-image:var(--icon-pencil)}.admonition-todo>.admonition-title{text-transform:uppercase}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd{margin-left:2rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd>:first-child{margin-top:.125rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list,dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd>:last-child{margin-bottom:.75rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list>dt{font-size:var(--font-size--small);text-transform:uppercase}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd:empty{margin-bottom:.5rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul{margin-left:-1.2rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul>li>p:nth-child(2){margin-top:0}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul>li>p+p:last-child:empty{margin-bottom:0;margin-top:0}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple)>dt{color:var(--color-api-overall)}.sig:not(.sig-inline){background:var(--color-api-background);border-radius:.25rem;font-family:var(--font-stack--monospace);font-size:var(--api-font-size);font-weight:700;margin-left:-.25rem;margin-right:-.25rem;padding:.25rem .5rem .25rem 3em;text-indent:-2.5em;transition:background .1s ease-out}.sig:not(.sig-inline):hover{background:var(--color-api-background-hover)}.sig:not(.sig-inline) a.reference .viewcode-link{font-weight:400;width:4.25rem}em.property{font-style:normal}em.property:first-child{color:var(--color-api-keyword)}.sig-name{color:var(--color-api-name)}.sig-prename{color:var(--color-api-pre-name);font-weight:400}.sig-paren{color:var(--color-api-paren)}.sig-param{font-style:normal}div.deprecated,div.versionadded,div.versionchanged,div.versionremoved{border-left:.1875rem solid;border-radius:.125rem;padding-left:.75rem}div.deprecated p,div.versionadded p,div.versionchanged p,div.versionremoved p{margin-bottom:.125rem;margin-top:.125rem}div.versionadded{border-color:var(--color-api-added-border)}div.versionadded .versionmodified{color:var(--color-api-added)}div.versionchanged{border-color:var(--color-api-changed-border)}div.versionchanged .versionmodified{color:var(--color-api-changed)}div.deprecated{border-color:var(--color-api-deprecated-border)}div.deprecated .versionmodified{color:var(--color-api-deprecated)}div.versionremoved{border-color:var(--color-api-removed-border)}div.versionremoved .versionmodified{color:var(--color-api-removed)}.viewcode-back,.viewcode-link{float:right;text-align:right}.line-block{margin-bottom:.75rem;margin-top:.5rem}.line-block .line-block{margin-bottom:0;margin-top:0;padding-left:1rem}.code-block-caption,article p.caption,table>caption{font-size:var(--font-size--small);text-align:center}.toctree-wrapper.compound .caption,.toctree-wrapper.compound :not(.caption)>.caption-text{font-size:var(--font-size--small);margin-bottom:0;text-align:initial;text-transform:uppercase}.toctree-wrapper.compound>ul{margin-bottom:0;margin-top:0}.sig-inline,code.literal{background:var(--color-inline-code-background);border-radius:.2em;font-size:var(--font-size--small--2);padding:.1em .2em}pre.literal-block .sig-inline,pre.literal-block code.literal{font-size:inherit;padding:0}p .sig-inline,p code.literal{border:1px solid var(--color-background-border)}.sig-inline{font-family:var(--font-stack--monospace)}div[class*=" highlight-"],div[class^=highlight-]{display:flex;margin:1em 0}div[class*=" highlight-"] .table-wrapper,div[class^=highlight-] .table-wrapper,pre{margin:0;padding:0}pre{overflow:auto}article[role=main] .highlight pre{line-height:1.5}.highlight pre,pre.literal-block{font-size:var(--code-font-size);padding:.625rem .875rem}pre.literal-block{background-color:var(--color-code-background);border-radius:.2rem;color:var(--color-code-foreground);margin-bottom:1rem;margin-top:1rem}.highlight{border-radius:.2rem;width:100%}.highlight .gp,.highlight span.linenos{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.highlight .hll{display:block;margin-left:-.875rem;margin-right:-.875rem;padding-left:.875rem;padding-right:.875rem}.code-block-caption{background-color:var(--color-code-background);border-bottom:1px solid;border-radius:.25rem;border-bottom-left-radius:0;border-bottom-right-radius:0;border-color:var(--color-background-border);color:var(--color-code-foreground);display:flex;font-weight:300;padding:.625rem .875rem}.code-block-caption+div[class]{margin-top:0}.code-block-caption+div[class] pre{border-top-left-radius:0;border-top-right-radius:0}.highlighttable{display:block;width:100%}.highlighttable tbody{display:block}.highlighttable tr{display:flex}.highlighttable td.linenos{background-color:var(--color-code-background);border-bottom-left-radius:.2rem;border-top-left-radius:.2rem;color:var(--color-code-foreground);padding:.625rem 0 .625rem .875rem}.highlighttable .linenodiv{box-shadow:-.0625rem 0 var(--color-foreground-border) inset;font-size:var(--code-font-size);padding-right:.875rem}.highlighttable td.code{display:block;flex:1;overflow:hidden;padding:0}.highlighttable td.code .highlight{border-bottom-left-radius:0;border-top-left-radius:0}.highlight span.linenos{box-shadow:-.0625rem 0 var(--color-foreground-border) inset;display:inline-block;margin-right:.875rem;padding-left:0;padding-right:.875rem}.footnote-reference{font-size:var(--font-size--small--4);vertical-align:super}dl.footnote.brackets{color:var(--color-foreground-secondary);display:grid;font-size:var(--font-size--small);grid-template-columns:max-content auto}dl.footnote.brackets dt{margin:0}dl.footnote.brackets dt>.fn-backref{margin-left:.25rem}dl.footnote.brackets dt:after{content:":"}dl.footnote.brackets dt .brackets:before{content:"["}dl.footnote.brackets dt .brackets:after{content:"]"}dl.footnote.brackets dd{margin:0;padding:0 1rem}aside.footnote{color:var(--color-foreground-secondary);font-size:var(--font-size--small)}aside.footnote>span,div.citation>span{float:left;font-weight:500;padding-right:.25rem}aside.footnote>:not(span),div.citation>p{margin-left:2rem}img{box-sizing:border-box;height:auto;max-width:100%}article .figure,article figure{border-radius:.2rem;margin:0}article .figure :last-child,article figure :last-child{margin-bottom:0}article .align-left{clear:left;float:left;margin:0 1rem 1rem}article .align-right{clear:right;float:right;margin:0 1rem 1rem}article .align-center,article .align-default{display:block;margin-left:auto;margin-right:auto;text-align:center}article table.align-default{display:table;text-align:initial}.domainindex-jumpbox,.genindex-jumpbox{border-bottom:1px solid var(--color-background-border);border-top:1px solid var(--color-background-border);padding:.25rem}.domainindex-section h2,.genindex-section h2{margin-bottom:.5rem;margin-top:.75rem}.domainindex-section ul,.genindex-section ul{margin-bottom:0;margin-top:0}ol,ul{margin-bottom:1rem;margin-top:1rem;padding-left:1.2rem}ol li>p:first-child,ul li>p:first-child{margin-bottom:.25rem;margin-top:.25rem}ol li>p:last-child,ul li>p:last-child{margin-top:.25rem}ol li>ol,ol li>ul,ul li>ol,ul li>ul{margin-bottom:.5rem;margin-top:.5rem}ol.arabic{list-style:decimal}ol.loweralpha{list-style:lower-alpha}ol.upperalpha{list-style:upper-alpha}ol.lowerroman{list-style:lower-roman}ol.upperroman{list-style:upper-roman}.simple li>ol,.simple li>ul,.toctree-wrapper li>ol,.toctree-wrapper li>ul{margin-bottom:0;margin-top:0}.field-list dt,.option-list dt,dl.footnote dt,dl.glossary dt,dl.simple dt,dl:not([class]) dt{font-weight:500;margin-top:.25rem}.field-list dt+dt,.option-list dt+dt,dl.footnote dt+dt,dl.glossary dt+dt,dl.simple dt+dt,dl:not([class]) dt+dt{margin-top:0}.field-list dt .classifier:before,.option-list dt .classifier:before,dl.footnote dt .classifier:before,dl.glossary dt .classifier:before,dl.simple dt .classifier:before,dl:not([class]) dt .classifier:before{content:":";margin-left:.2rem;margin-right:.2rem}.field-list dd ul,.field-list dd>p:first-child,.option-list dd ul,.option-list dd>p:first-child,dl.footnote dd ul,dl.footnote dd>p:first-child,dl.glossary dd ul,dl.glossary dd>p:first-child,dl.simple dd ul,dl.simple dd>p:first-child,dl:not([class]) dd ul,dl:not([class]) dd>p:first-child{margin-top:.125rem}.field-list dd ul,.option-list dd ul,dl.footnote dd ul,dl.glossary dd ul,dl.simple dd ul,dl:not([class]) dd ul{margin-bottom:.125rem}.math-wrapper{overflow-x:auto;width:100%}div.math{position:relative;text-align:center}div.math .headerlink,div.math:focus .headerlink{display:none}div.math:hover .headerlink{display:inline-block}div.math span.eqno{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);z-index:1}abbr[title]{cursor:help}.problematic{color:var(--color-problematic)}kbd:not(.compound){background-color:var(--color-background-secondary);border:1px solid var(--color-foreground-border);border-radius:.2rem;box-shadow:0 .0625rem 0 rgba(0,0,0,.2),inset 0 0 0 .125rem var(--color-background-primary);color:var(--color-foreground-primary);display:inline-block;font-size:var(--font-size--small--3);margin:0 .2rem;padding:0 .2rem;vertical-align:text-bottom}blockquote{background:var(--color-background-secondary);border-left:4px solid var(--color-background-border);margin-left:0;margin-right:0;padding:.5rem 1rem}blockquote .attribution{font-weight:600;text-align:right}blockquote.highlights,blockquote.pull-quote{font-size:1.25em}blockquote.epigraph,blockquote.pull-quote{border-left-width:0;border-radius:.5rem}blockquote.highlights{background:transparent;border-left-width:0}p .reference img{vertical-align:middle}p.rubric{font-size:1.125em;font-weight:700;line-height:1.25}dd p.rubric{font-size:var(--font-size--small);font-weight:inherit;line-height:inherit;text-transform:uppercase}article .sidebar{background-color:var(--color-background-secondary);border:1px solid var(--color-background-border);border-radius:.2rem;clear:right;float:right;margin-left:1rem;margin-right:0;width:30%}article .sidebar>*{padding-left:1rem;padding-right:1rem}article .sidebar>ol,article .sidebar>ul{padding-left:2.2rem}article .sidebar .sidebar-title{border-bottom:1px solid var(--color-background-border);font-weight:500;margin:0;padding:.5rem 1rem}[role=main] .table-wrapper.container{margin-bottom:.5rem;margin-top:1rem;overflow-x:auto;padding:.2rem .2rem .75rem;width:100%}table.docutils{border-collapse:collapse;border-radius:.2rem;border-spacing:0;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1)}table.docutils th{background:var(--color-table-header-background)}table.docutils td,table.docutils th{border-bottom:1px solid var(--color-table-border);border-left:1px solid var(--color-table-border);border-right:1px solid var(--color-table-border);padding:0 .25rem}table.docutils td p,table.docutils th p{margin:.25rem}table.docutils td:first-child,table.docutils th:first-child{border-left:none}table.docutils td:last-child,table.docutils th:last-child{border-right:none}table.docutils td.text-left,table.docutils th.text-left{text-align:left}table.docutils td.text-right,table.docutils th.text-right{text-align:right}table.docutils td.text-center,table.docutils th.text-center{text-align:center}:target{scroll-margin-top:2.5rem}@media(max-width:67em){:target{scroll-margin-top:calc(2.5rem + var(--header-height))}section>span:target{scroll-margin-top:calc(2.8rem + var(--header-height))}}.headerlink{font-weight:100;-webkit-user-select:none;-moz-user-select:none;user-select:none}.code-block-caption>.headerlink,dl dt>.headerlink,figcaption p>.headerlink,h1>.headerlink,h2>.headerlink,h3>.headerlink,h4>.headerlink,h5>.headerlink,h6>.headerlink,p.caption>.headerlink,table>caption>.headerlink{margin-left:.5rem;visibility:hidden}.code-block-caption:hover>.headerlink,dl dt:hover>.headerlink,figcaption p:hover>.headerlink,h1:hover>.headerlink,h2:hover>.headerlink,h3:hover>.headerlink,h4:hover>.headerlink,h5:hover>.headerlink,h6:hover>.headerlink,p.caption:hover>.headerlink,table>caption:hover>.headerlink{visibility:visible}.code-block-caption>.toc-backref,dl dt>.toc-backref,figcaption p>.toc-backref,h1>.toc-backref,h2>.toc-backref,h3>.toc-backref,h4>.toc-backref,h5>.toc-backref,h6>.toc-backref,p.caption>.toc-backref,table>caption>.toc-backref{color:inherit;text-decoration-line:none}figure:hover>figcaption>p>.headerlink,table:hover>caption>.headerlink{visibility:visible}:target>h1:first-of-type,:target>h2:first-of-type,:target>h3:first-of-type,:target>h4:first-of-type,:target>h5:first-of-type,:target>h6:first-of-type,span:target~h1:first-of-type,span:target~h2:first-of-type,span:target~h3:first-of-type,span:target~h4:first-of-type,span:target~h5:first-of-type,span:target~h6:first-of-type{background-color:var(--color-highlight-on-target)}:target>h1:first-of-type code.literal,:target>h2:first-of-type code.literal,:target>h3:first-of-type code.literal,:target>h4:first-of-type code.literal,:target>h5:first-of-type code.literal,:target>h6:first-of-type code.literal,span:target~h1:first-of-type code.literal,span:target~h2:first-of-type code.literal,span:target~h3:first-of-type code.literal,span:target~h4:first-of-type code.literal,span:target~h5:first-of-type code.literal,span:target~h6:first-of-type code.literal{background-color:transparent}.literal-block-wrapper:target .code-block-caption,.this-will-duplicate-information-and-it-is-still-useful-here li :target,figure:target,table:target>caption{background-color:var(--color-highlight-on-target)}dt:target{background-color:var(--color-highlight-on-target)!important}.footnote-reference:target,.footnote>dt:target+dd{background-color:var(--color-highlight-on-target)}.guilabel{background-color:var(--color-guilabel-background);border:1px solid var(--color-guilabel-border);border-radius:.5em;color:var(--color-guilabel-text);font-size:.9em;padding:0 .3em}footer{display:flex;flex-direction:column;font-size:var(--font-size--small);margin-top:2rem}.bottom-of-page{align-items:center;border-top:1px solid var(--color-background-border);color:var(--color-foreground-secondary);display:flex;justify-content:space-between;line-height:1.5;margin-top:1rem;padding-bottom:1rem;padding-top:1rem}@media(max-width:46em){.bottom-of-page{flex-direction:column-reverse;gap:.25rem;text-align:center}}.bottom-of-page .left-details{font-size:var(--font-size--small)}.bottom-of-page .right-details{display:flex;flex-direction:column;gap:.25rem;text-align:right}.bottom-of-page .icons{display:flex;font-size:1rem;gap:.25rem;justify-content:flex-end}.bottom-of-page .icons a{text-decoration:none}.bottom-of-page .icons img,.bottom-of-page .icons svg{font-size:1.125rem;height:1em;width:1em}.related-pages a{align-items:center;display:flex;text-decoration:none}.related-pages a:hover .page-info .title{color:var(--color-link);text-decoration:underline;text-decoration-color:var(--color-link-underline)}.related-pages a svg.furo-related-icon,.related-pages a svg.furo-related-icon>use{color:var(--color-foreground-border);flex-shrink:0;height:.75rem;margin:0 .5rem;width:.75rem}.related-pages a.next-page{clear:right;float:right;max-width:50%;text-align:right}.related-pages a.prev-page{clear:left;float:left;max-width:50%}.related-pages a.prev-page svg{transform:rotate(180deg)}.page-info{display:flex;flex-direction:column;overflow-wrap:anywhere}.next-page .page-info{align-items:flex-end}.page-info .context{align-items:center;color:var(--color-foreground-muted);display:flex;font-size:var(--font-size--small);padding-bottom:.1rem;text-decoration:none}ul.search{list-style:none;padding-left:0}ul.search li{border-bottom:1px solid var(--color-background-border);padding:1rem 0}[role=main] .highlighted{background-color:var(--color-highlighted-background);color:var(--color-highlighted-text)}.sidebar-brand{display:flex;flex-direction:column;flex-shrink:0;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);text-decoration:none}.sidebar-brand-text{color:var(--color-sidebar-brand-text);font-size:1.5rem;overflow-wrap:break-word}.sidebar-brand-text,.sidebar-logo-container{margin:var(--sidebar-item-spacing-vertical) 0}.sidebar-logo{display:block;margin:0 auto;max-width:100%}.sidebar-search-container{align-items:center;background:var(--color-sidebar-search-background);display:flex;margin-top:var(--sidebar-search-space-above);position:relative}.sidebar-search-container:focus-within,.sidebar-search-container:hover{background:var(--color-sidebar-search-background--focus)}.sidebar-search-container:before{background-color:var(--color-sidebar-search-icon);content:"";height:var(--sidebar-search-icon-size);left:var(--sidebar-item-spacing-horizontal);-webkit-mask-image:var(--icon-search);mask-image:var(--icon-search);position:absolute;width:var(--sidebar-search-icon-size)}.sidebar-search{background:transparent;border:none;border-bottom:1px solid var(--color-sidebar-search-border);border-top:1px solid var(--color-sidebar-search-border);box-sizing:border-box;color:var(--color-sidebar-search-foreground);padding:var(--sidebar-search-input-spacing-vertical) var(--sidebar-search-input-spacing-horizontal) var(--sidebar-search-input-spacing-vertical) calc(var(--sidebar-item-spacing-horizontal) + var(--sidebar-search-input-spacing-horizontal) + var(--sidebar-search-icon-size));width:100%;z-index:10}.sidebar-search:focus{outline:none}.sidebar-search::-moz-placeholder{font-size:var(--sidebar-search-input-font-size)}.sidebar-search::placeholder{font-size:var(--sidebar-search-input-font-size)}#searchbox .highlight-link{margin:0;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0;text-align:center}#searchbox .highlight-link a{color:var(--color-sidebar-search-icon);font-size:var(--font-size--small--2)}.sidebar-tree{font-size:var(--sidebar-item-font-size);margin-bottom:var(--sidebar-item-spacing-vertical);margin-top:var(--sidebar-tree-space-above)}.sidebar-tree ul{display:flex;flex-direction:column;list-style:none;margin-bottom:0;margin-top:0;padding:0}.sidebar-tree li{margin:0;position:relative}.sidebar-tree li>ul{margin-left:var(--sidebar-item-spacing-horizontal)}.sidebar-tree .icon,.sidebar-tree .reference{color:var(--color-sidebar-link-text)}.sidebar-tree .reference{box-sizing:border-box;display:inline-block;height:100%;line-height:var(--sidebar-item-line-height);overflow-wrap:anywhere;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);text-decoration:none;width:100%}.sidebar-tree .reference:hover{background:var(--color-sidebar-item-background--hover);color:var(--color-sidebar-link-text)}.sidebar-tree .reference.external:after{color:var(--color-sidebar-link-text);content:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23607D8B' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' viewBox='0 0 24 24'%3E%3Cpath stroke='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M11 7H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-5M10 14 20 4M15 4h5v5'/%3E%3C/svg%3E");margin:0 .25rem;vertical-align:middle}.sidebar-tree .current-page>.reference{font-weight:700}.sidebar-tree label{align-items:center;cursor:pointer;display:flex;height:var(--sidebar-item-height);justify-content:center;position:absolute;right:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:var(--sidebar-expander-width)}.sidebar-tree .caption,.sidebar-tree :not(.caption)>.caption-text{color:var(--color-sidebar-caption-text);font-size:var(--sidebar-caption-font-size);font-weight:700;margin:var(--sidebar-caption-space-above) 0 0 0;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);text-transform:uppercase}.sidebar-tree li.has-children>.reference{padding-right:var(--sidebar-expander-width)}.sidebar-tree .toctree-l1>.reference,.sidebar-tree .toctree-l1>label .icon{color:var(--color-sidebar-link-text--top-level)}.sidebar-tree label{background:var(--color-sidebar-item-expander-background)}.sidebar-tree label:hover{background:var(--color-sidebar-item-expander-background--hover)}.sidebar-tree .current>.reference{background:var(--color-sidebar-item-background--current)}.sidebar-tree .current>.reference:hover{background:var(--color-sidebar-item-background--hover)}.toctree-checkbox{display:none;position:absolute}.toctree-checkbox~ul{display:none}.toctree-checkbox~label .icon svg{transform:rotate(90deg)}.toctree-checkbox:checked~ul{display:block}.toctree-checkbox:checked~label .icon svg{transform:rotate(-90deg)}.toc-title-container{padding:var(--toc-title-padding);padding-top:var(--toc-spacing-vertical)}.toc-title{color:var(--color-toc-title-text);font-size:var(--toc-title-font-size);padding-left:var(--toc-spacing-horizontal);text-transform:uppercase}.no-toc{display:none}.toc-tree-container{padding-bottom:var(--toc-spacing-vertical)}.toc-tree{border-left:1px solid var(--color-background-border);font-size:var(--toc-font-size);line-height:1.3;padding-left:calc(var(--toc-spacing-horizontal) - var(--toc-item-spacing-horizontal))}.toc-tree>ul>li:first-child{padding-top:0}.toc-tree>ul>li:first-child>ul{padding-left:0}.toc-tree>ul>li:first-child>a{display:none}.toc-tree ul{list-style-type:none;margin-bottom:0;margin-top:0;padding-left:var(--toc-item-spacing-horizontal)}.toc-tree li{padding-top:var(--toc-item-spacing-vertical)}.toc-tree li.scroll-current>.reference{color:var(--color-toc-item-text--active);font-weight:700}.toc-tree a.reference{color:var(--color-toc-item-text);overflow-wrap:anywhere;text-decoration:none}.toc-scroll{max-height:100vh;overflow-y:scroll}.contents:not(.this-will-duplicate-information-and-it-is-still-useful-here){background:rgba(255,0,0,.25);color:var(--color-problematic)}.contents:not(.this-will-duplicate-information-and-it-is-still-useful-here):before{content:"ERROR: Adding a table of contents in Furo-based documentation is unnecessary, and does not work well with existing styling. Add a 'this-will-duplicate-information-and-it-is-still-useful-here' class, if you want an escape hatch."}.text-align\:left>p{text-align:left}.text-align\:center>p{text-align:center}.text-align\:right>p{text-align:right} /*# sourceMappingURL=furo.css.map*/ ================================================ FILE: docs/WechatAPIClient/_static/translations.js ================================================ Documentation.addTranslations({ "locale": "zh_Hans_CN", "messages": { "%(filename)s — %(docstitle)s": "%(filename)s — %(docstitle)s", "© %(copyright_prefix)s %(copyright)s.": "© %(copyright_prefix)s %(copyright)s.", ", in ": "\uff0c\u5728 ", "About these documents": "\u5173\u4e8e\u6b64\u6587\u6863", "Automatically generated list of changes in version %(version)s": "\u81ea\u52a8\u751f\u6210\u7684 %(version)s \u7248\u672c\u53d8\u66f4\u5217\u8868", "C API changes": "C API \u7684\u53d8\u66f4", "Changes in Version %(version)s — %(docstitle)s": "\u4e8e\u7248\u672c %(version)s— %(docstitle)s \u53d8\u66f4", "Collapse sidebar": "\u6298\u53e0\u8fb9\u680f", "Complete Table of Contents": "\u5b8c\u6574\u76ee\u5f55", "Contents": "\u76ee\u5f55", "Copyright": "\u7248\u6743\u6240\u6709", "Created using Sphinx %(sphinx_version)s.": "\u7531 Sphinx %(sphinx_version)s\u521b\u5efa\u3002", "Expand sidebar": "\u5c55\u5f00\u8fb9\u680f", "Full index on one page": "\u5355\u9875\u5168\u7d22\u5f15", "General Index": "\u603b\u7d22\u5f15", "Global Module Index": "\u5168\u5c40\u6a21\u5757\u7d22\u5f15", "Go": "\u63d0\u4ea4", "Hide Search Matches": "\u9690\u85cf\u641c\u7d22\u7ed3\u679c", "Index": "\u7d22\u5f15", "Index – %(key)s": "\u7d22\u5f15 – %(key)s", "Index pages by letter": "\u5b57\u6bcd\u7d22\u5f15", "Indices and tables:": "\u7d22\u5f15\u548c\u8868\u683c\uff1a", "Last updated on %(last_updated)s.": "\u6700\u540e\u66f4\u65b0\u4e8e %(last_updated)s.", "Library changes": "\u5e93\u7684\u53d8\u66f4", "Navigation": "\u5bfc\u822a", "Next topic": "\u4e0b\u4e00\u4e3b\u9898", "Other changes": "\u5176\u4ed6\u53d8\u66f4", "Overview": "\u6982\u8ff0", "Please activate JavaScript to enable the search\n functionality.": "\u8bf7\u6fc0\u6d3b JavaScript \u4ee5\u5f00\u542f\u641c\u7d22\u529f\u80fd\u3002", "Preparing search...": "\u6b63\u5728\u51c6\u5907\u641c\u7d22\u2026\u2026", "Previous topic": "\u4e0a\u4e00\u4e3b\u9898", "Quick search": "\u5feb\u901f\u641c\u7d22", "Search": "\u641c\u7d22", "Search Page": "\u641c\u7d22\u9875\u9762", "Search Results": "\u641c\u7d22\u7ed3\u679c", "Search finished, found ${resultCount} page(s) matching the search query.": "\u641c\u7d22\u5b8c\u6210\uff0c\u5339\u914d\u5230 ${resultCount} \u9875\u3002", "Search within %(docstitle)s": "\u5728 %(docstitle)s \u4e2d\u641c\u7d22", "Searching": "\u6b63\u5728\u641c\u7d22\u4e2d", "Searching for multiple words only shows matches that contain\n all words.": "\u5f53\u641c\u7d22\u591a\u4e2a\u5173\u952e\u8bcd\u65f6\uff0c\u53ea\u4f1a\u663e\u793a\u540c\u65f6\u5305\u542b\u6240\u6709\u5173\u952e\u8bcd\u7684\u5185\u5bb9\u3002", "Show Source": "\u663e\u793a\u6e90\u4ee3\u7801", "Table of Contents": "\u76ee\u5f55", "This Page": "\u672c\u9875", "Welcome! This is": "\u6b22\u8fce\uff01", "Your search did not match any documents. Please make sure that all words are spelled correctly and that you've selected enough categories.": "\u60a8\u7684\u641c\u7d22\u6ca1\u6709\u5339\u914d\u5230\u6587\u6863\u3002\u8bf7\u786e\u4fdd\u5173\u952e\u8bcd\u62fc\u5199\u6b63\u786e\uff0c\u5e76\u4e14\u9009\u62e9\u4e86\u5408\u9002\u7684\u5206\u7c7b\u3002", "all functions, classes, terms": "\u6240\u6709\u51fd\u6570\u3001\u7c7b\u3001\u672f\u8bed\u8bcd\u6c47", "can be huge": "\u53ef\u80fd\u4f1a\u5927", "last updated": "\u6700\u540e\u66f4\u65b0\u4e8e", "lists all sections and subsections": "\u5217\u51fa\u6240\u6709\u7684\u7ae0\u8282\u548c\u90e8\u5206", "next chapter": "\u4e0b\u4e00\u7ae0", "previous chapter": "\u4e0a\u4e00\u7ae0", "quick access to all modules": "\u5feb\u901f\u67e5\u770b\u6240\u6709\u7684\u6a21\u5757", "search": "\u641c\u7d22", "search this documentation": "\u641c\u7d22\u6587\u6863", "the documentation for": "\u672c\u6587\u6863\u5c5e\u4e8e" }, "plural_expr": "0" }); ================================================ FILE: docs/WechatAPIClient/genindex.html ================================================ 索引 - XYBotV2 Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
Back to top

索引

_ | A | B | C | D | E | F | G | H | I | L | M | P | R | S | T | U | W

_

A

B

C

D

E

F

G

H

I

L

M

P

R

S

T

U

W

================================================ FILE: docs/WechatAPIClient/index.html ================================================ XYBotV2 Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
Back to top

WechatAPIClient

基础

class WechatAPI.Client.base.Proxy(ip: str, port: int, username: str = '', password: str = '')[源代码]

基类:object

代理(无效果,别用!)

参数:
  • ip (str) -- 代理服务器IP地址

  • port (int) -- 代理服务器端口

  • username (str, optional) -- 代理认证用户名. 默认为空字符串

  • password (str, optional) -- 代理认证密码. 默认为空字符串

ip: str
password: str = ''
port: int
username: str = ''
class WechatAPI.Client.base.Section(data_len: int, start_pos: int)[源代码]

基类:object

数据段配置类

参数:
  • data_len (int) -- 数据长度

  • start_pos (int) -- 起始位置

data_len: int
start_pos: int
class WechatAPI.Client.base.WechatAPIClientBase(ip: str, port: int)[源代码]

基类:object

微信API客户端基类

参数:
  • ip (str) -- 服务器IP地址

  • port (int) -- 服务器端口

变量:
  • wxid (str) -- 微信ID

  • nickname (str) -- 昵称

  • alias (str) -- 别名

  • phone (str) -- 手机号

  • ignore_protect (bool) -- 是否忽略保护机制

static error_handler(json_resp)[源代码]

处理API响应中的错误码

参数:

json_resp (dict) -- API响应的JSON数据

抛出:
  • ValueError -- 参数错误时抛出

  • MarshallingError -- 序列化错误时抛出

  • UnmarshallingError -- 反序列化错误时抛出

  • MMTLSError -- MMTLS初始化错误时抛出

  • PacketError -- 数据包长度错误时抛出

  • UserLoggedOut -- 用户已退出登录时抛出

  • ParsePacketError -- 解析数据包错误时抛出

  • DatabaseError -- 数据库错误时抛出

  • Exception -- 其他类型错误时抛出

登录

class WechatAPI.Client.login.LoginMixin(ip: str, port: int)[源代码]

基类:WechatAPIClientBase

async awaken_login(wxid: str = '') str[源代码]

唤醒登录。

参数:

wxid (str, optional) -- 要唤醒的微信ID. Defaults to "".

返回:

返回新的登录UUID

返回类型:

str

抛出:
  • Exception -- 如果未提供wxid且未登录

  • LoginError -- 如果无法获取UUID

  • 根据error_handler处理错误 --

async check_login_uuid(uuid: str, device_id: str = '') tuple[bool, dict | int][源代码]

检查登录的UUID状态。

参数:
  • uuid (str) -- 登录的UUID

  • device_id (str, optional) -- 设备ID. Defaults to "".

返回:

如果登录成功返回(True, 用户信息),否则返回(False, 过期时间)

返回类型:

tuple[bool, Union[dict, int]]

抛出:

根据error_handler处理错误 --

static create_device_id(s: str = '') str[源代码]

生成设备ID。

参数:

s (str, optional) -- 用于生成ID的字符串. Defaults to "".

返回:

返回生成的设备ID

返回类型:

str

static create_device_name() str[源代码]

生成一个随机的设备名。

返回:

返回生成的设备名

返回类型:

str

async get_auto_heartbeat_status() bool[源代码]

获取自动心跳状态。

返回:

如果正在运行返回True,否则返回False

返回类型:

bool

抛出:
  • UserLoggedOut -- 如果未登录时调用

  • 根据error_handler处理错误 --

async get_cached_info(wxid: str = None) dict[源代码]

获取登录缓存信息。

参数:

wxid (str, optional) -- 要查询的微信ID. Defaults to None.

返回:

返回缓存信息,如果未提供wxid且未登录返回空字典

返回类型:

dict

async get_qr_code(device_name: str, device_id: str = '', proxy: ~WechatAPI.Client.base.Proxy = None, print_qr: bool = False) -> (<class 'str'>, <class 'str'>)[源代码]

获取登录二维码。

参数:
  • device_name (str) -- 设备名称

  • device_id (str, optional) -- 设备ID. Defaults to "".

  • proxy (Proxy, optional) -- 代理信息. Defaults to None.

  • print_qr (bool, optional) -- 是否在控制台打印二维码. Defaults to False.

返回:

返回登录二维码的UUID和URL

返回类型:

tuple[str, str]

抛出:

根据error_handler处理错误 --

async heartbeat() bool[源代码]

发送心跳包。

返回:

成功返回True,否则返回False

返回类型:

bool

抛出:
  • UserLoggedOut -- 如果未登录时调用

  • 根据error_handler处理错误 --

async is_running() bool[源代码]

检查WechatAPI是否在运行。

返回:

如果WechatAPI正在运行返回True,否则返回False。

返回类型:

bool

async log_out() bool[源代码]

登出当前账号。

返回:

登出成功返回True,否则返回False

返回类型:

bool

抛出:
  • UserLoggedOut -- 如果未登录时调用

  • 根据error_handler处理错误 --

async start_auto_heartbeat() bool[源代码]

开始自动心跳。

返回:

成功返回True,否则返回False

返回类型:

bool

抛出:
  • UserLoggedOut -- 如果未登录时调用

  • 根据error_handler处理错误 --

async stop_auto_heartbeat() bool[源代码]

停止自动心跳。

返回:

成功返回True,否则返回False

返回类型:

bool

抛出:
  • UserLoggedOut -- 如果未登录时调用

  • 根据error_handler处理错误 --

消息

class WechatAPI.Client.message.MessageMixin(ip: str, port: int)[源代码]

基类:WechatAPIClientBase

async _process_message_queue()[源代码]

处理消息队列的异步方法

async _queue_message(func, *args, **kwargs)[源代码]

将消息添加到队列

async _send_text_message(wxid: str, content: str, at: list[str] = None) tuple[int, int, int][源代码]

实际发送文本消息的方法

async revoke_message(wxid: str, client_msg_id: int, create_time: int, new_msg_id: int) bool[源代码]

撤回消息。

参数:
  • wxid (str) -- 接收人wxid

  • client_msg_id (int) -- 发送消息的返回值

  • create_time (int) -- 发送消息的返回值

  • new_msg_id (int) -- 发送消息的返回值

返回:

成功返回True,失败返回False

返回类型:

bool

抛出:
  • UserLoggedOut -- 未登录时调用

  • BanProtection -- 登录新设备后4小时内操作

  • 根据error_handler处理错误 --

async send_app_message(wxid: str, xml: str, type: int) tuple[str, int, int][源代码]

发送应用消息。

参数:
  • wxid (str) -- 接收人wxid

  • xml (str) -- 应用消息的xml内容

  • type (int) -- 应用消息类型

返回:

返回(ClientMsgid, CreateTime, NewMsgId)

返回类型:

tuple[str, int, int]

抛出:
  • UserLoggedOut -- 未登录时调用

  • BanProtection -- 登录新设备后4小时内操作

  • 根据error_handler处理错误 --

async send_card_message(wxid: str, card_wxid: str, card_nickname: str, card_alias: str = '') tuple[int, int, int][源代码]

发送名片消息。

参数:
  • wxid (str) -- 接收人wxid

  • card_wxid (str) -- 名片用户的wxid

  • card_nickname (str) -- 名片用户的昵称

  • card_alias (str, optional) -- 名片用户的备注. Defaults to "".

返回:

返回(ClientMsgid, CreateTime, NewMsgId)

返回类型:

tuple[int, int, int]

抛出:
  • UserLoggedOut -- 未登录时调用

  • BanProtection -- 登录新设备后4小时内操作

  • 根据error_handler处理错误 --

async send_cdn_file_msg(wxid: str, xml: str) tuple[str, int, int][源代码]

转发文件消息。

参数:
  • wxid (str) -- 接收人wxid

  • xml (str) -- 要转发的文件消息xml内容

返回:

返回(ClientMsgid, CreateTime, NewMsgId)

返回类型:

tuple[str, int, int]

抛出:
  • UserLoggedOut -- 未登录时调用

  • BanProtection -- 登录新设备后4小时内操作

  • 根据error_handler处理错误 --

async send_cdn_img_msg(wxid: str, xml: str) tuple[str, int, int][源代码]

转发图片消息。

参数:
  • wxid (str) -- 接收人wxid

  • xml (str) -- 要转发的图片消息xml内容

返回:

返回(ClientImgId, CreateTime, NewMsgId)

返回类型:

tuple[str, int, int]

抛出:
  • UserLoggedOut -- 未登录时调用

  • BanProtection -- 登录新设备后4小时内操作

  • 根据error_handler处理错误 --

async send_cdn_video_msg(wxid: str, xml: str) tuple[str, int][源代码]

转发视频消息。

参数:
  • wxid (str) -- 接收人wxid

  • xml (str) -- 要转发的视频消息xml内容

返回:

返回(ClientMsgid, NewMsgId)

返回类型:

tuple[str, int]

抛出:
  • UserLoggedOut -- 未登录时调用

  • BanProtection -- 登录新设备后4小时内操作

  • 根据error_handler处理错误 --

async send_emoji_message(wxid: str, md5: str, total_length: int) list[dict][源代码]

发送表情消息。

参数:
  • wxid (str) -- 接收人wxid

  • md5 (str) -- 表情md5值

  • total_length (int) -- 表情总长度

返回:

返回表情项列表(list of emojiItem)

返回类型:

list[dict]

抛出:
  • UserLoggedOut -- 未登录时调用

  • BanProtection -- 登录新设备后4小时内操作

  • 根据error_handler处理错误 --

async send_image_message(wxid: str, image: str | bytes | PathLike) tuple[int, int, int][源代码]

发送图片消息。

参数:
  • wxid (str) -- 接收人wxid

  • image (str, byte, os.PathLike) -- 图片,支持base64字符串,图片byte,图片路径

返回:

返回(ClientImgId, CreateTime, NewMsgId)

返回类型:

tuple[int, int, int]

抛出:
  • UserLoggedOut -- 未登录时调用

  • BanProtection -- 登录新设备后4小时内操作

  • ValueError -- image_path和image_base64都为空或都不为空时

  • 根据error_handler处理错误 --

发送链接消息。

参数:
  • wxid (str) -- 接收人wxid

  • url (str) -- 跳转链接

  • title (str, optional) -- 标题. Defaults to "".

  • description (str, optional) -- 描述. Defaults to "".

  • thumb_url (str, optional) -- 缩略图链接. Defaults to "".

返回:

返回(ClientMsgid, CreateTime, NewMsgId)

返回类型:

tuple[str, int, int]

抛出:
  • UserLoggedOut -- 未登录时调用

  • BanProtection -- 登录新设备后4小时内操作

  • 根据error_handler处理错误 --

async send_text_message(wxid: str, content: str, at: list | str = '') tuple[int, int, int][源代码]

发送文本消息。

参数:
  • wxid (str) -- 接收人wxid

  • content (str) -- 消息内容

  • at (list, str, optional) -- 要@的用户

返回:

返回(ClientMsgid, CreateTime, NewMsgId)

返回类型:

tuple[int, int, int]

抛出:
  • UserLoggedOut -- 未登录时调用

  • BanProtection -- 登录新设备后4小时内操作

  • 根据error_handler处理错误 --

async send_video_message(wxid: str, video: str | bytes | ~os.PathLike, image: [<class 'str'>, <class 'bytes'>, <class 'os.PathLike'>] = None)[源代码]

发送视频消息。不推荐使用,上传速度很慢300KB/s。如要使用,可压缩视频,或者发送链接卡片而不是视频。

参数:
  • wxid (str) -- 接收人wxid

  • video (str, bytes, os.PathLike) -- 视频 接受base64字符串,字节,文件路径

  • image (str, bytes, os.PathLike) -- 视频封面图片 接受base64字符串,字节,文件路径

返回:

返回(ClientMsgid, NewMsgId)

返回类型:

tuple[int, int]

抛出:
  • UserLoggedOut -- 未登录时调用

  • BanProtection -- 登录新设备后4小时内操作

  • ValueError -- 视频或图片参数都为空或都不为空时

  • 根据error_handler处理错误 --

async send_voice_message(wxid: str, voice: str | bytes | PathLike, format: str = 'amr') tuple[int, int, int][源代码]

发送语音消息。

参数:
  • wxid (str) -- 接收人wxid

  • voice (str, bytes, os.PathLike) -- 语音 接受base64字符串,字节,文件路径

  • format (str, optional) -- 语音格式,支持amr/wav/mp3. Defaults to "amr".

返回:

返回(ClientMsgid, CreateTime, NewMsgId)

返回类型:

tuple[int, int, int]

抛出:
  • UserLoggedOut -- 未登录时调用

  • BanProtection -- 登录新设备后4小时内操作

  • ValueError -- voice_path和voice_base64都为空或都不为空时,或format不支持时

  • 根据error_handler处理错误 --

async sync_message() dict[源代码]

同步消息。

返回:

返回同步到的消息数据

返回类型:

dict

抛出:
  • UserLoggedOut -- 未登录时调用

  • 根据error_handler处理错误 --

用户

class WechatAPI.Client.user.UserMixin(ip: str, port: int)[源代码]

基类:WechatAPIClientBase

async get_my_qrcode(style: int = 0) str[源代码]

获取个人二维码。

参数:

style (int, optional) -- 二维码样式. Defaults to 0.

返回:

图片的base64编码字符串

返回类型:

str

抛出:
  • UserLoggedOut -- 未登录时调用

  • BanProtection -- 风控保护: 新设备登录后4小时内请挂机

  • 根据error_handler处理错误 --

async get_profile(wxid: str = None) dict[源代码]

获取用户信息。

参数:

wxid (str, optional) -- 用户wxid. Defaults to None.

返回:

用户信息字典

返回类型:

dict

抛出:
  • UserLoggedOut -- 未登录时调用

  • 根据error_handler处理错误 --

async is_logged_in(wxid: str = None) bool[源代码]

检查是否登录。

参数:

wxid (str, optional) -- 用户wxid. Defaults to None.

返回:

已登录返回True,未登录返回False

返回类型:

bool

群聊

class WechatAPI.Client.chatroom.ChatroomMixin(ip: str, port: int)[源代码]

基类:WechatAPIClientBase

async add_chatroom_member(chatroom: str, wxid: str) bool[源代码]

添加群成员(群聊最多40人)

参数:
  • chatroom -- 群聊wxid

  • wxid -- 要添加的wxid

返回:

成功返回True, 失败False或者报错

返回类型:

bool

async get_chatroom_announce(chatroom: str) dict[源代码]

获取群聊公告

参数:

chatroom -- 群聊id

返回:

群聊信息字典

返回类型:

dict

async get_chatroom_info(chatroom: str) dict[源代码]

获取群聊信息

参数:

chatroom -- 群聊id

返回:

群聊信息字典

返回类型:

dict

async get_chatroom_member_list(chatroom: str) list[dict][源代码]

获取群聊成员列表

参数:

chatroom -- 群聊id

返回:

群聊成员列表

返回类型:

list[dict]

async get_chatroom_qrcode(chatroom: str) dict[str, Any][源代码]

获取群聊二维码

参数:

chatroom -- 群聊id

返回:

{"base64": 二维码的base64, "description": 二维码描述}

返回类型:

dict

async invite_chatroom_member(wxid: str | list, chatroom: str) bool[源代码]

邀请群聊成员(群聊大于40人)

参数:
  • wxid -- 要邀请的用户wxid或wxid列表

  • chatroom -- 群聊id

返回:

成功返回True, 失败False或者报错

返回类型:

bool

好友

class WechatAPI.Client.friend.FriendMixin(ip: str, port: int)[源代码]

基类:WechatAPIClientBase

async accept_friend(scene: int, v1: str, v2: str) bool[源代码]

接受好友请求

主动添加好友单天上限如下所示:1小时内上限为 5个,超过上限时,无法发出好友请求,也收不到好友请求。

  • 新账号:5/天

  • 注册超过7天:10个/天

  • 注册满3个月&&近期登录过该电脑:15/天

  • 注册满6个月&&近期经常登录过该电脑:20/天

  • 注册满6个月&&近期频繁登陆过该电脑:30/天

  • 注册1年以上&&一直登录:50/天

  • 上一次通过好友到下一次通过间隔20-40s

  • 收到加人申请,到通过好友申请(每天最多通过300个好友申请),间隔30s+(随机时间)

参数:
  • scene -- 来源 在消息的xml获取

  • v1 -- v1key

  • v2 -- v2key

返回:

操作是否成功

返回类型:

bool

async get_contact(wxid: str | list[str]) dict | list[dict][源代码]

获取联系人信息

参数:

wxid -- 联系人wxid, 可以是多个wxid在list里,也可查询chatroom

返回:

单个联系人返回dict,多个联系人返回list[dict]

返回类型:

Union[dict, list[dict]]

async get_contract_detail(wxid: str | list[str], chatroom: str = '') list[源代码]

获取联系人详情

参数:
  • wxid -- 联系人wxid

  • chatroom -- 群聊wxid

返回:

联系人详情列表

返回类型:

list

async get_contract_list(wx_seq: int = 0, chatroom_seq: int = 0) dict[源代码]

获取联系人列表

参数:
  • wx_seq -- 联系人序列

  • chatroom_seq -- 群聊序列

返回:

联系人列表数据

返回类型:

dict

async get_nickname(wxid: str | list[str]) str | list[str][源代码]

获取用户昵称

参数:

wxid -- 用户wxid,可以是单个wxid或最多20个wxid的列表

返回:

如果输入单个wxid返回str,如果输入wxid列表则返回对应的昵称列表

返回类型:

Union[str, list[str]]

红包

class WechatAPI.Client.hongbao.HongBaoMixin(ip: str, port: int)[源代码]

基类:WechatAPIClientBase

async get_hongbao_detail(xml: str, encrypt_key: str, encrypt_userinfo: str) dict[源代码]

获取红包详情

参数:
  • xml -- 红包 XML 数据

  • encrypt_key -- 加密密钥

  • encrypt_userinfo -- 加密的用户信息

返回:

红包详情数据

返回类型:

dict

保护

class WechatAPI.Client.protect.Protect(*args, **kwargs)[源代码]

基类:object

保护类,风控保护机制。

使用单例模式确保全局只有一个实例。

变量:
  • login_stat_path (str) -- 登录状态文件的路径

  • login_stat (dict) -- 登录状态信息

  • login_time (int) -- 最后登录时间戳

  • login_device_id (str) -- 最后登录的设备ID

__init__()[源代码]

初始化保护类实例。

创建或加载登录状态文件,初始化登录时间和设备ID。

check(second: int) bool[源代码]

检查是否在指定时间内,风控保护。

参数:

second (int) -- 指定的秒数

返回:

如果当前时间与上次登录时间的差小于指定秒数,返回True;否则返回False

返回类型:

bool

update_login_status(device_id: str = '')[源代码]

更新登录状态。

如果设备ID发生变化,更新登录时间和设备ID,并保存到文件。

参数:

device_id (str, optional) -- 设备ID. Defaults to "".

class WechatAPI.Client.protect.Singleton[源代码]

基类:type

单例模式的元类。

用于确保一个类只有一个实例。

变量:

_instances (dict) -- 存储类的实例的字典

__call__(*args, **kwargs)[源代码]

创建或返回类的单例实例。

参数:
  • *args -- 位置参数

  • **kwargs -- 关键字参数

返回:

类的单例实例

返回类型:

object

工具

class WechatAPI.Client.tool.ToolMixin(ip: str, port: int)[源代码]

基类:WechatAPIClientBase

static base64_to_byte(base64_str: str) bytes[源代码]

将base64字符串转换为bytes。

参数:

base64_str (str) -- base64编码的字符串

返回:

解码后的字节数据

返回类型:

bytes

static base64_to_file(base64_str: str, file_name: str, file_path: str) bool[源代码]

将base64字符串转换为文件并保存。

参数:
  • base64_str (str) -- base64编码的字符串

  • file_name (str) -- 要保存的文件名

  • file_path (str) -- 文件保存路径

返回:

转换成功返回True,失败返回False

返回类型:

bool

static byte_to_base64(byte: bytes) str[源代码]

将bytes转换为base64字符串。

参数:

byte (bytes) -- 字节数据

返回:

base64编码的字符串

返回类型:

str

async check_database() bool[源代码]

检查数据库状态。

返回:

数据库正常返回True,否则返回False

返回类型:

bool

async download_attach(attach_id: str) dict[源代码]

下载附件。

参数:

attach_id (str) -- 附件ID

返回:

附件数据

返回类型:

dict

抛出:
  • UserLoggedOut -- 未登录时调用

  • 根据error_handler处理错误 --

async download_image(aeskey: str, cdnmidimgurl: str) str[源代码]

CDN下载高清图片。

参数:
  • aeskey (str) -- 图片的AES密钥

  • cdnmidimgurl (str) -- 图片的CDN URL

返回:

图片的base64编码字符串

返回类型:

str

抛出:
  • UserLoggedOut -- 未登录时调用

  • 根据error_handler处理错误 --

async download_video(msg_id) str[源代码]

下载视频。

参数:

msg_id (str) -- 消息的msg_id

返回:

视频的base64编码字符串

返回类型:

str

抛出:
  • UserLoggedOut -- 未登录时调用

  • 根据error_handler处理错误 --

async download_voice(msg_id: str, voiceurl: str, length: int) str[源代码]

下载语音文件。

参数:
  • msg_id (str) -- 消息的msgid

  • voiceurl (str) -- 语音的url,从xml获取

  • length (int) -- 语音长度,从xml获取

返回:

语音的base64编码字符串

返回类型:

str

抛出:
  • UserLoggedOut -- 未登录时调用

  • 根据error_handler处理错误 --

static file_to_base64(file_path: str) str[源代码]

将文件转换为base64字符串。

参数:

file_path (str) -- 文件路径

返回:

base64编码的字符串

返回类型:

str

async set_proxy(proxy: Proxy) bool[源代码]

设置代理。

参数:

proxy (Proxy) -- 代理配置对象

返回:

成功返回True,失败返回False

返回类型:

bool

抛出:
  • UserLoggedOut -- 未登录时调用

  • 根据error_handler处理错误 --

async set_step(count: int) bool[源代码]

设置步数。

参数:

count (int) -- 要设置的步数

返回:

成功返回True,失败返回False

返回类型:

bool

抛出:
  • UserLoggedOut -- 未登录时调用

  • BanProtection -- 风控保护: 新设备登录后4小时内请挂机

  • 根据error_handler处理错误 --

async static silk_base64_to_wav_byte(silk_base64: str) bytes[源代码]

将silk格式的base64字符串转换为WAV字节数据。

参数:

silk_base64 (str) -- silk格式的base64编码字符串

返回:

WAV格式的字节数据

返回类型:

bytes

async static silk_byte_to_byte_wav_byte(silk_byte: bytes) bytes[源代码]

将silk字节转换为wav字节。

参数:

silk_byte (bytes) -- silk格式的字节数据

返回:

wav格式的字节数据

返回类型:

bytes

static wav_byte_to_amr_base64(wav_byte: bytes) str[源代码]

将WAV字节数据转换为AMR格式的base64字符串。

参数:

wav_byte (bytes) -- WAV格式的字节数据

返回:

AMR格式的base64编码字符串

返回类型:

str

static wav_byte_to_amr_byte(wav_byte: bytes) bytes[源代码]

将WAV字节数据转换为AMR格式。

参数:

wav_byte (bytes) -- WAV格式的字节数据

返回:

AMR格式的字节数据

返回类型:

bytes

抛出:

Exception -- 转换失败时抛出异常

async static wav_byte_to_silk_base64(wav_byte: bytes) str[源代码]

将WAV字节数据转换为silk格式的base64字符串。

参数:

wav_byte (bytes) -- WAV格式的字节数据

返回:

silk格式的base64编码字符串

返回类型:

str

async static wav_byte_to_silk_byte(wav_byte: bytes) bytes[源代码]

将WAV字节数据转换为silk格式。

参数:

wav_byte (bytes) -- WAV格式的字节数据

返回:

silk格式的字节数据

返回类型:

bytes

索引

================================================ FILE: docs/WechatAPIClient/py-modindex.html ================================================ Python 模块索引 - XYBotV2 Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
Back to top
================================================ FILE: docs/WechatAPIClient/search.html ================================================ 搜索 - XYBotV2 Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
Back to top
================================================ FILE: docs/WechatAPIClient/searchindex.js ================================================ Search.setIndex({ "alltitles": { "WechatAPIClient": [[0, null]], "\u4fdd\u62a4": [[0, "module-WechatAPI.Client.protect"]], "\u57fa\u7840": [[0, "module-WechatAPI.Client.base"]], "\u597d\u53cb": [[0, "module-WechatAPI.Client.friend"]], "\u5de5\u5177": [[0, "module-WechatAPI.Client.tool"]], "\u6d88\u606f": [[0, "module-WechatAPI.Client.message"]], "\u7528\u6237": [[0, "module-WechatAPI.Client.user"]], "\u767b\u5f55": [[0, "module-WechatAPI.Client.login"]], "\u7d22\u5f15": [[0, "id10"]], "\u7ea2\u5305": [[0, "module-WechatAPI.Client.hongbao"]], "\u7fa4\u804a": [[0, "module-WechatAPI.Client.chatroom"]] }, "docnames": ["index"], "envversion": { "sphinx": 64, "sphinx.domains.c": 3, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 9, "sphinx.domains.index": 1, "sphinx.domains.javascript": 3, "sphinx.domains.math": 2, "sphinx.domains.python": 4, "sphinx.domains.rst": 2, "sphinx.domains.std": 2, "sphinx.ext.viewcode": 1 }, "filenames": ["index.rst"], "indexentries": { "__call__() \uff08wechatapi.client.protect.singleton \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.protect.Singleton.__call__", false]], "__init__() \uff08wechatapi.client.protect.protect \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.protect.Protect.__init__", false]], "_process_message_queue() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin._process_message_queue", false]], "_queue_message() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin._queue_message", false]], "_send_text_message() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin._send_text_message", false]], "accept_friend() \uff08wechatapi.client.friend.friendmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.friend.FriendMixin.accept_friend", false]], "add_chatroom_member() \uff08wechatapi.client.chatroom.chatroommixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.chatroom.ChatroomMixin.add_chatroom_member", false]], "awaken_login() \uff08wechatapi.client.login.loginmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.login.LoginMixin.awaken_login", false]], "base64_to_byte()\uff08wechatapi.client.tool.toolmixin \u9759\u6001\u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.base64_to_byte", false]], "base64_to_file()\uff08wechatapi.client.tool.toolmixin \u9759\u6001\u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.base64_to_file", false]], "byte_to_base64()\uff08wechatapi.client.tool.toolmixin \u9759\u6001\u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.byte_to_base64", false]], "chatroommixin\uff08wechatapi.client.chatroom \u4e2d\u7684\u7c7b\uff09": [[0, "WechatAPI.Client.chatroom.ChatroomMixin", false]], "check() \uff08wechatapi.client.protect.protect \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.protect.Protect.check", false]], "check_database() \uff08wechatapi.client.tool.toolmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.check_database", false]], "check_login_uuid() \uff08wechatapi.client.login.loginmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.login.LoginMixin.check_login_uuid", false]], "create_device_id()\uff08wechatapi.client.login.loginmixin \u9759\u6001\u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.login.LoginMixin.create_device_id", false]], "create_device_name()\uff08wechatapi.client.login.loginmixin \u9759\u6001\u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.login.LoginMixin.create_device_name", false]], "data_len\uff08wechatapi.client.base.section \u5c5e\u6027\uff09": [[0, "WechatAPI.Client.base.Section.data_len", false]], "download_attach() \uff08wechatapi.client.tool.toolmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.download_attach", false]], "download_image() \uff08wechatapi.client.tool.toolmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.download_image", false]], "download_video() \uff08wechatapi.client.tool.toolmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.download_video", false]], "download_voice() \uff08wechatapi.client.tool.toolmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.download_voice", false]], "error_handler()\uff08wechatapi.client.base.wechatapiclientbase \u9759\u6001\u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.base.WechatAPIClientBase.error_handler", false]], "file_to_base64()\uff08wechatapi.client.tool.toolmixin \u9759\u6001\u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.file_to_base64", false]], "friendmixin\uff08wechatapi.client.friend \u4e2d\u7684\u7c7b\uff09": [[0, "WechatAPI.Client.friend.FriendMixin", false]], "get_auto_heartbeat_status() \uff08wechatapi.client.login.loginmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.login.LoginMixin.get_auto_heartbeat_status", false]], "get_cached_info() \uff08wechatapi.client.login.loginmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.login.LoginMixin.get_cached_info", false]], "get_chatroom_announce() \uff08wechatapi.client.chatroom.chatroommixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.chatroom.ChatroomMixin.get_chatroom_announce", false]], "get_chatroom_info() \uff08wechatapi.client.chatroom.chatroommixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.chatroom.ChatroomMixin.get_chatroom_info", false]], "get_chatroom_member_list() \uff08wechatapi.client.chatroom.chatroommixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.chatroom.ChatroomMixin.get_chatroom_member_list", false]], "get_chatroom_qrcode() \uff08wechatapi.client.chatroom.chatroommixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.chatroom.ChatroomMixin.get_chatroom_qrcode", false]], "get_contact() \uff08wechatapi.client.friend.friendmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.friend.FriendMixin.get_contact", false]], "get_contract_detail() \uff08wechatapi.client.friend.friendmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.friend.FriendMixin.get_contract_detail", false]], "get_contract_list() \uff08wechatapi.client.friend.friendmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.friend.FriendMixin.get_contract_list", false]], "get_hongbao_detail() \uff08wechatapi.client.hongbao.hongbaomixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.hongbao.HongBaoMixin.get_hongbao_detail", false]], "get_my_qrcode() \uff08wechatapi.client.user.usermixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.user.UserMixin.get_my_qrcode", false]], "get_nickname() \uff08wechatapi.client.friend.friendmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.friend.FriendMixin.get_nickname", false]], "get_profile() \uff08wechatapi.client.user.usermixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.user.UserMixin.get_profile", false]], "get_qr_code() \uff08wechatapi.client.login.loginmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.login.LoginMixin.get_qr_code", false]], "heartbeat() \uff08wechatapi.client.login.loginmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.login.LoginMixin.heartbeat", false]], "hongbaomixin\uff08wechatapi.client.hongbao \u4e2d\u7684\u7c7b\uff09": [[0, "WechatAPI.Client.hongbao.HongBaoMixin", false]], "invite_chatroom_member() \uff08wechatapi.client.chatroom.chatroommixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.chatroom.ChatroomMixin.invite_chatroom_member", false]], "ip\uff08wechatapi.client.base.proxy \u5c5e\u6027\uff09": [[0, "WechatAPI.Client.base.Proxy.ip", false]], "is_logged_in() \uff08wechatapi.client.user.usermixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.user.UserMixin.is_logged_in", false]], "is_running() \uff08wechatapi.client.login.loginmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.login.LoginMixin.is_running", false]], "log_out() \uff08wechatapi.client.login.loginmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.login.LoginMixin.log_out", false]], "loginmixin\uff08wechatapi.client.login \u4e2d\u7684\u7c7b\uff09": [[0, "WechatAPI.Client.login.LoginMixin", false]], "messagemixin\uff08wechatapi.client.message \u4e2d\u7684\u7c7b\uff09": [[0, "WechatAPI.Client.message.MessageMixin", false]], "module": [[0, "module-WechatAPI.Client.base", false], [0, "module-WechatAPI.Client.chatroom", false], [0, "module-WechatAPI.Client.friend", false], [0, "module-WechatAPI.Client.hongbao", false], [0, "module-WechatAPI.Client.login", false], [0, "module-WechatAPI.Client.message", false], [0, "module-WechatAPI.Client.protect", false], [0, "module-WechatAPI.Client.tool", false], [0, "module-WechatAPI.Client.user", false]], "password\uff08wechatapi.client.base.proxy \u5c5e\u6027\uff09": [[0, "WechatAPI.Client.base.Proxy.password", false]], "port\uff08wechatapi.client.base.proxy \u5c5e\u6027\uff09": [[0, "WechatAPI.Client.base.Proxy.port", false]], "protect\uff08wechatapi.client.protect \u4e2d\u7684\u7c7b\uff09": [[0, "WechatAPI.Client.protect.Protect", false]], "proxy\uff08wechatapi.client.base \u4e2d\u7684\u7c7b\uff09": [[0, "WechatAPI.Client.base.Proxy", false]], "revoke_message() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin.revoke_message", false]], "section\uff08wechatapi.client.base \u4e2d\u7684\u7c7b\uff09": [[0, "WechatAPI.Client.base.Section", false]], "send_app_message() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin.send_app_message", false]], "send_card_message() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin.send_card_message", false]], "send_cdn_file_msg() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin.send_cdn_file_msg", false]], "send_cdn_img_msg() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin.send_cdn_img_msg", false]], "send_cdn_video_msg() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin.send_cdn_video_msg", false]], "send_emoji_message() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin.send_emoji_message", false]], "send_image_message() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin.send_image_message", false]], "send_link_message() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin.send_link_message", false]], "send_text_message() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin.send_text_message", false]], "send_video_message() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin.send_video_message", false]], "send_voice_message() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin.send_voice_message", false]], "set_proxy() \uff08wechatapi.client.tool.toolmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.set_proxy", false]], "set_step() \uff08wechatapi.client.tool.toolmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.set_step", false]], "silk_base64_to_wav_byte()\uff08wechatapi.client.tool.toolmixin \u9759\u6001\u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.silk_base64_to_wav_byte", false]], "silk_byte_to_byte_wav_byte()\uff08wechatapi.client.tool.toolmixin \u9759\u6001\u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.silk_byte_to_byte_wav_byte", false]], "singleton\uff08wechatapi.client.protect \u4e2d\u7684\u7c7b\uff09": [[0, "WechatAPI.Client.protect.Singleton", false]], "start_auto_heartbeat() \uff08wechatapi.client.login.loginmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.login.LoginMixin.start_auto_heartbeat", false]], "start_pos\uff08wechatapi.client.base.section \u5c5e\u6027\uff09": [[0, "WechatAPI.Client.base.Section.start_pos", false]], "stop_auto_heartbeat() \uff08wechatapi.client.login.loginmixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.login.LoginMixin.stop_auto_heartbeat", false]], "sync_message() \uff08wechatapi.client.message.messagemixin \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.message.MessageMixin.sync_message", false]], "toolmixin\uff08wechatapi.client.tool \u4e2d\u7684\u7c7b\uff09": [[0, "WechatAPI.Client.tool.ToolMixin", false]], "update_login_status() \uff08wechatapi.client.protect.protect \u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.protect.Protect.update_login_status", false]], "usermixin\uff08wechatapi.client.user \u4e2d\u7684\u7c7b\uff09": [[0, "WechatAPI.Client.user.UserMixin", false]], "username\uff08wechatapi.client.base.proxy \u5c5e\u6027\uff09": [[0, "WechatAPI.Client.base.Proxy.username", false]], "wav_byte_to_amr_base64()\uff08wechatapi.client.tool.toolmixin \u9759\u6001\u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.wav_byte_to_amr_base64", false]], "wav_byte_to_amr_byte()\uff08wechatapi.client.tool.toolmixin \u9759\u6001\u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.wav_byte_to_amr_byte", false]], "wav_byte_to_silk_base64()\uff08wechatapi.client.tool.toolmixin \u9759\u6001\u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.wav_byte_to_silk_base64", false]], "wav_byte_to_silk_byte()\uff08wechatapi.client.tool.toolmixin \u9759\u6001\u65b9\u6cd5\uff09": [[0, "WechatAPI.Client.tool.ToolMixin.wav_byte_to_silk_byte", false]], "wechatapi.client.base": [[0, "module-WechatAPI.Client.base", false]], "wechatapi.client.chatroom": [[0, "module-WechatAPI.Client.chatroom", false]], "wechatapi.client.friend": [[0, "module-WechatAPI.Client.friend", false]], "wechatapi.client.hongbao": [[0, "module-WechatAPI.Client.hongbao", false]], "wechatapi.client.login": [[0, "module-WechatAPI.Client.login", false]], "wechatapi.client.message": [[0, "module-WechatAPI.Client.message", false]], "wechatapi.client.protect": [[0, "module-WechatAPI.Client.protect", false]], "wechatapi.client.tool": [[0, "module-WechatAPI.Client.tool", false]], "wechatapi.client.user": [[0, "module-WechatAPI.Client.user", false]], "wechatapiclientbase\uff08wechatapi.client.base \u4e2d\u7684\u7c7b\uff09": [[0, "WechatAPI.Client.base.WechatAPIClientBase", false]] }, "objects": { "WechatAPI.Client": [[0, 0, 0, "-", "base"], [0, 0, 0, "-", "chatroom"], [0, 0, 0, "-", "friend"], [0, 0, 0, "-", "hongbao"], [0, 0, 0, "-", "login"], [0, 0, 0, "-", "message"], [0, 0, 0, "-", "protect"], [0, 0, 0, "-", "tool"], [0, 0, 0, "-", "user"]], "WechatAPI.Client.base": [[0, 1, 1, "", "Proxy"], [0, 1, 1, "", "Section"], [0, 1, 1, "", "WechatAPIClientBase"]], "WechatAPI.Client.base.Proxy": [[0, 2, 1, "", "ip"], [0, 2, 1, "", "password"], [0, 2, 1, "", "port"], [0, 2, 1, "", "username"]], "WechatAPI.Client.base.Section": [[0, 2, 1, "", "data_len"], [0, 2, 1, "", "start_pos"]], "WechatAPI.Client.base.WechatAPIClientBase": [[0, 3, 1, "", "error_handler"]], "WechatAPI.Client.chatroom": [[0, 1, 1, "", "ChatroomMixin"]], "WechatAPI.Client.chatroom.ChatroomMixin": [[0, 3, 1, "", "add_chatroom_member"], [0, 3, 1, "", "get_chatroom_announce"], [0, 3, 1, "", "get_chatroom_info"], [0, 3, 1, "", "get_chatroom_member_list"], [0, 3, 1, "", "get_chatroom_qrcode"], [0, 3, 1, "", "invite_chatroom_member"]], "WechatAPI.Client.friend": [[0, 1, 1, "", "FriendMixin"]], "WechatAPI.Client.friend.FriendMixin": [[0, 3, 1, "", "accept_friend"], [0, 3, 1, "", "get_contact"], [0, 3, 1, "", "get_contract_detail"], [0, 3, 1, "", "get_contract_list"], [0, 3, 1, "", "get_nickname"]], "WechatAPI.Client.hongbao": [[0, 1, 1, "", "HongBaoMixin"]], "WechatAPI.Client.hongbao.HongBaoMixin": [[0, 3, 1, "", "get_hongbao_detail"]], "WechatAPI.Client.login": [[0, 1, 1, "", "LoginMixin"]], "WechatAPI.Client.login.LoginMixin": [[0, 3, 1, "", "awaken_login"], [0, 3, 1, "", "check_login_uuid"], [0, 3, 1, "", "create_device_id"], [0, 3, 1, "", "create_device_name"], [0, 3, 1, "", "get_auto_heartbeat_status"], [0, 3, 1, "", "get_cached_info"], [0, 3, 1, "", "get_qr_code"], [0, 3, 1, "", "heartbeat"], [0, 3, 1, "", "is_running"], [0, 3, 1, "", "log_out"], [0, 3, 1, "", "start_auto_heartbeat"], [0, 3, 1, "", "stop_auto_heartbeat"]], "WechatAPI.Client.message": [[0, 1, 1, "", "MessageMixin"]], "WechatAPI.Client.message.MessageMixin": [[0, 3, 1, "", "_process_message_queue"], [0, 3, 1, "", "_queue_message"], [0, 3, 1, "", "_send_text_message"], [0, 3, 1, "", "revoke_message"], [0, 3, 1, "", "send_app_message"], [0, 3, 1, "", "send_card_message"], [0, 3, 1, "", "send_cdn_file_msg"], [0, 3, 1, "", "send_cdn_img_msg"], [0, 3, 1, "", "send_cdn_video_msg"], [0, 3, 1, "", "send_emoji_message"], [0, 3, 1, "", "send_image_message"], [0, 3, 1, "", "send_link_message"], [0, 3, 1, "", "send_text_message"], [0, 3, 1, "", "send_video_message"], [0, 3, 1, "", "send_voice_message"], [0, 3, 1, "", "sync_message"]], "WechatAPI.Client.protect": [[0, 1, 1, "", "Protect"], [0, 1, 1, "", "Singleton"]], "WechatAPI.Client.protect.Protect": [[0, 3, 1, "", "__init__"], [0, 3, 1, "", "check"], [0, 3, 1, "", "update_login_status"]], "WechatAPI.Client.protect.Singleton": [[0, 3, 1, "", "__call__"]], "WechatAPI.Client.tool": [[0, 1, 1, "", "ToolMixin"]], "WechatAPI.Client.tool.ToolMixin": [[0, 3, 1, "", "base64_to_byte"], [0, 3, 1, "", "base64_to_file"], [0, 3, 1, "", "byte_to_base64"], [0, 3, 1, "", "check_database"], [0, 3, 1, "", "download_attach"], [0, 3, 1, "", "download_image"], [0, 3, 1, "", "download_video"], [0, 3, 1, "", "download_voice"], [0, 3, 1, "", "file_to_base64"], [0, 3, 1, "", "set_proxy"], [0, 3, 1, "", "set_step"], [0, 3, 1, "", "silk_base64_to_wav_byte"], [0, 3, 1, "", "silk_byte_to_byte_wav_byte"], [0, 3, 1, "", "wav_byte_to_amr_base64"], [0, 3, 1, "", "wav_byte_to_amr_byte"], [0, 3, 1, "", "wav_byte_to_silk_base64"], [0, 3, 1, "", "wav_byte_to_silk_byte"]], "WechatAPI.Client.user": [[0, 1, 1, "", "UserMixin"]], "WechatAPI.Client.user.UserMixin": [[0, 3, 1, "", "get_my_qrcode"], [0, 3, 1, "", "get_profile"], [0, 3, 1, "", "is_logged_in"]] }, "objnames": { "0": ["py", "module", "Python \u6a21\u5757"], "1": ["py", "class", "Python \u7c7b"], "2": ["py", "attribute", "Python \u5c5e\u6027"], "3": ["py", "method", "Python \u65b9\u6cd5"] }, "objtypes": {"0": "py:module", "1": "py:class", "2": "py:attribute", "3": "py:method"}, "terms": { "&&": 0, "--": 0, "10": 0, "15": 0, "20": 0, "30": 0, "300": 0, "300kb": 0, "30s": 0, "40": 0, "40s": 0, "50": 0, "__": 0, "__call__": 0, "__init__": 0, "_instanc": 0, "_process_message_queu": 0, "_queue_messag": 0, "_send_text_messag": 0, "accept": 0, "accept_friend": 0, "add": 0, "add_chatroom_memb": 0, "aes": 0, "aeskey": 0, "alia": 0, "amr": 0, "ani": 0, "announc": 0, "api": 0, "app": 0, "arg": 0, "async": 0, "at": 0, "attach": 0, "attach_id": 0, "auto": 0, "awaken": 0, "awaken_login": 0, "banprotect": 0, "base": 0, "base64": 0, "base64_str": 0, "base64_to_byt": 0, "base64_to_fil": 0, "bool": 0, "byte": 0, "byte_to_base64": 0, "cach": 0, "call": 0, "card": 0, "card_alia": 0, "card_nicknam": 0, "card_wxid": 0, "cdn": 0, "cdnmidimgurl": 0, "chatroom": 0, "chatroom_seq": 0, "chatroommixin": 0, "check": 0, "check_databas": 0, "check_login_uuid": 0, "class": 0, "client": 0, "client_msg_id": 0, "clientimgid": 0, "clientmsgid": 0, "code": 0, "contact": 0, "content": 0, "contract": 0, "count": 0, "creat": 0, "create_device_id": 0, "create_device_nam": 0, "create_tim": 0, "createtim": 0, "data": 0, "data_len": 0, "databas": 0, "databaseerror": 0, "default": 0, "descript": 0, "detail": 0, "devic": 0, "device_id": 0, "device_nam": 0, "dict": 0, "download": 0, "download_attach": 0, "download_imag": 0, "download_video": 0, "download_voic": 0, "emoji": 0, "emojiitem": 0, "encrypt": 0, "encrypt_key": 0, "encrypt_userinfo": 0, "error": 0, "error_handl": 0, "except": 0, "fals": 0, "file": 0, "file_nam": 0, "file_path": 0, "file_to_base64": 0, "format": 0, "friend": 0, "friendmixin": 0, "func": 0, "get": 0, "get_auto_heartbeat_status": 0, "get_cached_info": 0, "get_chatroom_announc": 0, "get_chatroom_info": 0, "get_chatroom_member_list": 0, "get_chatroom_qrcod": 0, "get_contact": 0, "get_contract_detail": 0, "get_contract_list": 0, "get_hongbao_detail": 0, "get_my_qrcod": 0, "get_nicknam": 0, "get_profil": 0, "get_qr_cod": 0, "handler": 0, "heartbeat": 0, "hongbao": 0, "hongbaomixin": 0, "id": 0, "ignor": 0, "ignore_protect": 0, "imag": 0, "image_base64": 0, "image_path": 0, "img": 0, "in": 0, "info": 0, "init": 0, "instanc": 0, "int": 0, "invit": 0, "invite_chatroom_memb": 0, "ip": 0, "ip\u5730\u5740": 0, "is": 0, "is_logged_in": 0, "is_run": 0, "json": 0, "json_resp": 0, "key": 0, "kwarg": 0, "len": 0, "length": 0, "link": 0, "list": 0, "log": 0, "log_out": 0, "login": 0, "login_device_id": 0, "login_stat": 0, "login_stat_path": 0, "login_tim": 0, "loginerror": 0, "loginmixin": 0, "marshallingerror": 0, "md5": 0, "member": 0, "messag": 0, "messagemixin": 0, "mmtls": 0, "mmtlserror": 0, "mp3": 0, "msg": 0, "msg_id": 0, "msgid": 0, "my": 0, "name": 0, "new": 0, "new_msg_id": 0, "newmsgid": 0, "nicknam": 0, "none": 0, "object": 0, "of": 0, "option": 0, "os": 0, "out": 0, "packeterror": 0, "parsepacketerror": 0, "password": 0, "path": 0, "pathlik": 0, "phone": 0, "port": 0, "pos": 0, "print": 0, "print_qr": 0, "process": 0, "profil": 0, "protect": 0, "proxi": 0, "qr": 0, "qrcode": 0, "queue": 0, "resp": 0, "revok": 0, "revoke_messag": 0, "run": 0, "scene": 0, "second": 0, "section": 0, "send": 0, "send_app_messag": 0, "send_card_messag": 0, "send_cdn_file_msg": 0, "send_cdn_img_msg": 0, "send_cdn_video_msg": 0, "send_emoji_messag": 0, "send_image_messag": 0, "send_link_messag": 0, "send_text_messag": 0, "send_video_messag": 0, "send_voice_messag": 0, "seq": 0, "set": 0, "set_proxi": 0, "set_step": 0, "silk": 0, "silk_base64": 0, "silk_base64_to_wav_byt": 0, "silk_byt": 0, "silk_byte_to_byte_wav_byt": 0, "singleton": 0, "start": 0, "start_auto_heartbeat": 0, "start_po": 0, "stat": 0, "static": 0, "status": 0, "step": 0, "stop": 0, "stop_auto_heartbeat": 0, "str": 0, "style": 0, "sync": 0, "sync_messag": 0, "text": 0, "thumb": 0, "thumb_url": 0, "time": 0, "titl": 0, "to": 0, "tool": 0, "toolmixin": 0, "total": 0, "total_length": 0, "true": 0, "tupl": 0, "type": 0, "union": 0, "unmarshallingerror": 0, "updat": 0, "update_login_status": 0, "url": 0, "user": 0, "userinfo": 0, "userloggedout": 0, "usermixin": 0, "usernam": 0, "uuid": 0, "v1": 0, "v1key": 0, "v2": 0, "v2key": 0, "valueerror": 0, "video": 0, "voic": 0, "voice_base64": 0, "voice_path": 0, "voiceurl": 0, "wav": 0, "wav_byt": 0, "wav_byte_to_amr_base64": 0, "wav_byte_to_amr_byt": 0, "wav_byte_to_silk_base64": 0, "wav_byte_to_silk_byt": 0, "wechatapi": 0, "wechatapiclientbas": 0, "wx": 0, "wx_seq": 0, "wxid": 0, "xml": 0, "\u4e00\u4e2a": 0, "\u4e00\u6b21": 0, "\u4e00\u76f4": 0, "\u4e0a\u4f20": 0, "\u4e0a\u6b21": 0, "\u4e0a\u9650": 0, "\u4e0b\u8f7d": 0, "\u4e0d\u5230": 0, "\u4e0d\u662f": 0, "\u4e2a\u4eba": 0, "\u4e3a\u7a7a": 0, "\u4e3b\u52a8": 0, "\u4e8c\u7ef4": 0, "\u4e8c\u7ef4\u7801": 0, "\u4ee3\u7406": 0, "\u4ee3\u7406\u670d\u52a1\u5668": 0, "\u4ee3\u7801": 0, "\u4ee5\u4e0a": 0, "\u4f4d\u7f6e": 0, "\u4f7f\u7528": 0, "\u4fdd\u5b58": 0, "\u4fe1\u606f": 0, "\u505c\u6b62": 0, "\u5143\u7c7b": 0, "\u5168\u5c40": 0, "\u516c\u544a": 0, "\u5173\u952e": 0, "\u5173\u952e\u5b57": 0, "\u5176\u4ed6": 0, "\u5185\u5bb9": 0, "\u5185\u8bf7": 0, "\u5217\u8868": 0, "\u521b\u5efa": 0, "\u521d\u59cb": 0, "\u521d\u59cb\u5316": 0, "\u522b\u540d": 0, "\u522b\u7528": 0, "\u52a0\u5bc6": 0, "\u52a0\u8f7d": 0, "\u52a1\u5668": 0, "\u5355\u4e2a": 0, "\u5355\u4f8b": 0, "\u5355\u5929": 0, "\u5361\u7247": 0, "\u538b\u7f29": 0, "\u53c2\u6570": 0, "\u53d1\u51fa": 0, "\u53d1\u751f": 0, "\u53d1\u751f\u53d8\u5316": 0, "\u53d1\u9001": 0, "\u53d8\u5316": 0, "\u53d8\u91cf": 0, "\u53ea\u6709": 0, "\u53ef\u4ee5": 0, "\u53ef\u538b": 0, "\u53ef\u538b\u7f29": 0, "\u540c\u6b65": 0, "\u540d\u7247": 0, "\u540d\u79f0": 0, "\u5426\u5219": 0, "\u54cd\u5e94": 0, "\u5524\u9192": 0, "\u56fe\u7247": 0, "\u5730\u5740": 0, "\u57fa\u7c7b": 0, "\u5904\u7406": 0, "\u5904\u7406\u9519\u8bef": 0, "\u5907\u6ce8": 0, "\u591a\u4e2a": 0, "\u5927\u4e8e": 0, "\u5931\u8d25": 0, "\u5982\u4e0b": 0, "\u5982\u679c": 0, "\u5b57\u5178": 0, "\u5b57\u7b26": 0, "\u5b57\u7b26\u4e32": 0, "\u5b57\u8282": 0, "\u5b58\u50a8": 0, "\u5b9e\u4f8b": 0, "\u5b9e\u9645": 0, "\u5ba2\u6237": 0, "\u5ba2\u6237\u7aef": 0, "\u5bc6\u7801": 0, "\u5bc6\u94a5": 0, "\u5bf9\u5e94": 0, "\u5bf9\u8c61": 0, "\u5c01\u9762": 0, "\u5c0f\u4e8e": 0, "\u5c0f\u65f6": 0, "\u5e8f\u5217": 0, "\u5e8f\u5217\u5316": 0, "\u5e94\u7528": 0, "\u5f00\u59cb": 0, "\u5f02\u5e38": 0, "\u5f02\u6b65": 0, "\u5f53\u524d": 0, "\u5f88\u6162": 0, "\u5fae\u4fe1": 0, "\u5fc3\u8df3": 0, "\u5ffd\u7565": 0, "\u603b\u957f": 0, "\u603b\u957f\u5ea6": 0, "\u6210\u529f": 0, "\u6210\u5458": 0, "\u6216\u8005": 0, "\u6237\u540d": 0, "\u6240\u793a": 0, "\u624b\u673a": 0, "\u624b\u673a\u53f7": 0, "\u6253\u5370": 0, "\u629b\u51fa": 0, "\u62a5\u9519": 0, "\u6302\u673a": 0, "\u6307\u5b9a": 0, "\u636e\u5e93": 0, "\u63a5\u53d7": 0, "\u63a5\u6536": 0, "\u63a7\u5236": 0, "\u63a7\u5236\u53f0": 0, "\u63a8\u8350": 0, "\u63cf\u8ff0": 0, "\u63d0\u4f9b": 0, "\u641c\u7d22": 0, "\u64a4\u56de": 0, "\u64cd\u4f5c": 0, "\u652f\u6301": 0, "\u6536\u4e0d\u5230": 0, "\u6536\u5230": 0, "\u6548\u679c": 0, "\u6570\u636e": 0, "\u6570\u636e\u5305": 0, "\u6570\u636e\u5e93": 0, "\u6587\u4ef6": 0, "\u6587\u4ef6\u540d": 0, "\u6587\u672c": 0, "\u65b9\u6cd5": 0, "\u65e0\u6cd5": 0, "\u65f6\u95f4": 0, "\u662f\u5426": 0, "\u6635\u79f0": 0, "\u66f4\u65b0": 0, "\u6700\u540e": 0, "\u6700\u591a": 0, "\u670d\u52a1": 0, "\u670d\u52a1\u5668": 0, "\u670d\u52a1\u5668\u7aef": 0, "\u673a\u5236": 0, "\u6765\u6e90": 0, "\u67e5\u8be2": 0, "\u6807\u9898": 0, "\u6837\u5f0f": 0, "\u6839\u636e": 0, "\u683c\u5f0f": 0, "\u68c0\u67e5": 0, "\u6a21\u5f0f": 0, "\u6b63\u5728": 0, "\u6b63\u5e38": 0, "\u6b65\u6570": 0, "\u6bcf\u5929": 0, "\u6ce8\u518c": 0, "\u6dfb\u52a0": 0, "\u6e90\u4ee3\u7801": 0, "\u72b6\u6001": 0, "\u751f\u53d8": 0, "\u751f\u6210": 0, "\u7528\u4e8e": 0, "\u7528\u6237\u540d": 0, "\u7533\u8bf7": 0, "\u7535\u8111": 0, "\u7565\u56fe": 0, "\u767b\u51fa": 0, "\u767b\u9646": 0, "\u786e\u4fdd": 0, "\u79d2\u6570": 0, "\u7a7a\u65f6": 0, "\u7aef\u53e3": 0, "\u7c7b\u578b": 0, "\u7ecf\u5e38": 0, "\u7f13\u5b58": 0, "\u7f16\u7801": 0, "\u7f29\u7565": 0, "\u7f29\u7565\u56fe": 0, "\u8054\u7cfb": 0, "\u8054\u7cfb\u4eba": 0, "\u81ea\u52a8": 0, "\u83b7\u53d6": 0, "\u8868\u60c5": 0, "\u89c6\u9891": 0, "\u89e3\u6790": 0, "\u89e3\u7801": 0, "\u8ba4\u8bc1": 0, "\u8bbe\u5907": 0, "\u8bbe\u7f6e": 0, "\u8be6\u60c5": 0, "\u8bed\u97f3": 0, "\u8bef\u7801": 0, "\u8bf7\u6c42": 0, "\u8c03\u7528": 0, "\u8d26\u53f7": 0, "\u8d77\u59cb": 0, "\u8d85\u8fc7": 0, "\u8def\u5f84": 0, "\u8df3\u8f6c": 0, "\u8f6c\u53d1": 0, "\u8f6c\u6362": 0, "\u8f93\u5165": 0, "\u8fc7\u671f": 0, "\u8fc7\u8be5": 0, "\u8fd0\u884c": 0, "\u8fd1\u671f": 0, "\u8fd4\u56de": 0, "\u8fd4\u56de\u503c": 0, "\u9000\u51fa": 0, "\u901a\u8fc7": 0, "\u901f\u5ea6": 0, "\u9080\u8bf7": 0, "\u914d\u7f6e": 0, "\u94fe\u63a5": 0, "\u9519\u8bef": 0, "\u9519\u8bef\u7801": 0, "\u957f\u5ea6": 0, "\u95f4\u9694": 0, "\u961f\u5217": 0, "\u9644\u4ef6": 0, "\u968f\u673a": 0, "\u9875\u9762": 0, "\u9891\u7e41": 0, "\u98ce\u63a7": 0, "\u9ad8\u6e05": 0, "\u9ed8\u8ba4": 0 }, "titles": ["WechatAPIClient"], "titleterms": { "wechatapicli": 0, "\u4fdd\u62a4": 0, "\u57fa\u7840": 0, "\u597d\u53cb": 0, "\u5de5\u5177": 0, "\u6d88\u606f": 0, "\u7528\u6237": 0, "\u767b\u5f55": 0, "\u7d22\u5f15": 0, "\u7ea2\u5305": 0, "\u7fa4\u804a": 0 } }) ================================================ FILE: docs/_coverpage.md ================================================ # XYBotV2 > 🤖 功能丰富的微信机器人框架 - AI对话、对接DeepSeek、积分系统、游戏互动、每日新闻、天气查询 - 非Hook、非微信网页版 - 支持: Windows ✅ Linux ✅ MacOS ✅ - 全新架构解决XYBot第一代痛点! [GitHub](https://github.com/HenryXiaoYang/XYBotV2) [开始使用](README.md) ================================================ FILE: docs/_sidebar.md ================================================ * [🏠 首页](/) * 📖 部署教程 * [🪟 Windows部署](/zh_cn/Windows部署.md) * [🐧 Linux部署](/zh_cn/Linux部署.md) * [🐳 Docker部署(推荐)](/zh_cn/Docker部署.md) * [🔧 配置文件](/zh_cn/配置文件.md) * [⚙️ Dify配置](/zh_cn/Dify插件配置.md) * 📚 开发文档 * [🔌 插件开发](/zh_cn/插件开发.md) * 🔗 WechatAPIClient文档 ================================================ FILE: docs/index.html ================================================ XYBotV2
================================================ FILE: docs/zh_cn/Dify插件配置.md ================================================ # Dify插件配置 用于接入 Dify AI 对话能力的功能模块。 ```toml # plugins/Dify/config.toml [Dify] enable = true # 是否启用此功能 api-key = "" # Dify的API Key,必填 base-url = "https://api.dify.ai/v1" # Dify API接口地址 # 支持的指令列表 commands = ["ai", "dify", "聊天", "AI"] # 指令提示信息 command-tip = """-----XYBot----- 💬AI聊天指令: 聊天 请求内容 """ # 其他插件的指令,避免冲突 other-plugin-cmd = ["status", "bot", ...] # 其他插件指令列表 price = 0 # 每次使用扣除的积分,0表示不扣除 admin_ignore = true # 管理员是否忽略积分扣除 whitelist_ignore = true # 白名单用户是否忽略积分扣除 # Http代理设置 # 格式: http://用户名:密码@代理地址:代理端口 # 例如:http://127.0.0.1:7890 http-proxy = "" ``` ## 配置说明 1. 基础配置 - `enable`: 是否启用Dify插件 - `api-key`: Dify平台的API密钥,必须填写 - `base-url`: Dify API的接口地址,默认为 https://api.dify.ai/v1 2. 指令配置 - `commands`: 支持的指令列表,可以添加多个指令 - `command-tip`: 指令提示信息,可以自定义 - `other-plugin-cmd`: 其他插件的指令,避免冲突 3. 积分配置 - `price`: 每次使用扣除的积分,0表示不扣除 - `admin_ignore`: 管理员是否忽略积分扣除 - `whitelist_ignore`: 白名单用户是否忽略积分扣除 4. 代理配置 - `http-proxy`: Http代理设置(可选) ## 使用示例 1. 基础对话 `帮我写一首诗` 2. 群聊中使用 `@机器人 今天心情不好` ## 注意事项 - API密钥不要泄露 - 建议配置代理以提高访问稳定性 - 合理设置积分规则,避免滥用 ## 获取API密钥方法 1. 登录 [Dify](https://dify.ai/) 平台 2. 创建或选择一个应用 - 可导入本项目提供的 [Dify应用模版](https://github.com/HenryXiaoYang/XYBotV2/blob/main/XYBot_Dify_Template.yml) - 可配置使用的AI模型 3. 在左侧导航栏找到"访问API" 4. 创建新的API密钥 5. 将获得的密钥填入配置文件的 api-key 字段 Dify相关信息: - Dify官方文档: https://docs.dify.ai/zh-hans - CSDN的教程:https://blog.csdn.net/2301_81940605/article/details/143730438 - 学会使用搜索引擎: https://www.bing.com/search?q=Dify+API+新手教程 - 学会使用搜索引擎: https://www.google.com/search?q=Dify+API+新手教程 - 学会使用Github: https://github.com/langgenius/dify # Dify ## 什么是 Dify? Dify 是一个开源的大语言模型(LLM)应用开发平台,旨在帮助开发者快速构建和部署 AI 应用。它提供了强大的 API 接口,支持多种模型的接入和使用,适合各种场景的应用开发。 ## 如何配置Dify ### 步骤 1:访问 Dify 网站 1. 打开浏览器,访问 [Dify 官方网站](https://dify.ai/zh)。 2. 点击右上角的"开始使用"按钮,输入你的账号信息进行登录。如果没有账号,请先注册。 ![Dify开始使用](assets/Dify开始使用.png) ### 步骤 2:创建应用 1. 登录后,在控制面板中选择"导入DSL文件"。 2. 把`https://github.com/HenryXiaoYang/XYBotV2/blob/main/XYBot_Dify_Template.yml`下载下来,导入到Dify中。 ![Dify下载模版](assets/Dify下载模版.png) ![Dify导入文件](assets/Dify导入文件.png) 3. 点击"创建"按钮。 ![Dify创建应用](assets/Dify创建应用.png) ### 步骤 3:获取 API 密钥 1. 在应用创建成功后,会进入应用设置页面。 2. 找到左侧栏"访问API"选项,点击进入。 ![Dify访问API](assets/Dify访问API.png) 3. 保存API服务器网址,然后点击右上角"API 密钥" ![DifyAPI密钥](assets/DifyAPI密钥.png) 4. 点击"创建密钥" ![Dify创建密钥](assets/Dify创建密钥.png) 5. 请妥善保存密钥 ![Dify保存密钥](assets/Dify保存密钥.png) ### 步骤 4:配置模型供应商 1. 点击右上角你的用户名,再点击"设置" ![Dify个人设置](assets/Dify个人设置.png) 2. 点击"模型供应商" ![Dify个人设置模型供应商](assets/Dify个人设置模型供应商.png) 3. 按需求配置模型 ![Dify配置模型供应商](assets/Dify配置模型供应商.png) - 技巧:配置OpenAIAPI格式的模型 ![Dify配置OpenAI API模型](assets/DifyOpenAIAPI.png) 如果模型服务不在Dify默认支持服务中,并且模型服务有OpenAI格式的API服务,那么可以使用`OpenAI-API-compatible`让Dify适配OpenAI API。 ### 步骤 5:配置编排 1. 点击左侧栏"编排"选项 ![Dify编排](assets/Dify编排.png) 2. 有三个LLM节点需要配置 ![Dify三个LLM节点](assets/Dify三个LLM节点.png) 3. 点击一个节点,在选择右边栏的模型 ![Dify点击节点](assets/Dify点击节点.png) 4. 选择一个合适的模型 ![Dify选择模型](assets/Dify选择模型.png) - 注意,这个节点选择的模型需要支持图片输入 ![Dify模型支持图片输入](assets/Dify模型支持图片输入.png) ### 步骤 6: 发布 1. 点击右上角"发布",再点一次"发布" ![Dify发布](assets/Dify发布.png) ### 步骤 7: 配置XYBot 1. 回到XYBot项目,在`plugins/Dify/config.toml`文件中写入你保存的API网址和密钥。 ```toml # plugins/Dify/config.toml [Dify] enable = true api-key = "" # 写入你保存的API密钥 base-url = "https://api.dify.ai/v1" # 写入你保存的API网址 #...下面略...# ``` 2. 保存文件,重载Dify插件 ## 参考链接 - [Dify 官方文档](https://docs.dify.ai/zh-hans) - [模型配置指南](https://docs.dify.ai/zh-hans/guides/model-configuration) - [工作流指南](https://docs.dify.ai/zh-hans/guides/workflow) - [API 开发指南](https://docs.dify.ai/zh-hans/guides/application-publishing/developing-with-apis) ================================================ FILE: docs/zh_cn/Docker部署.md ================================================ # 🐳 Docker 部署 ## 1. 🔧 准备环境 需要安装 Docker 和 Docker Compose: - 🐋 Docker 安装: https://docs.docker.com/get-started/get-docker/ - 🔄 Docker Compose 安装: https://docs.docker.com/compose/install/ 2. ⬇️ 拉取最新镜像 ```bash # 克隆项目 git clone https://github.com/HenryXiaoYang/XYBotV2.git cd XYBotV2 # 拉取镜像 docker-compose pull ``` 3. 🚀 启动容器 ```bash # 首次启动 docker-compose up -d # 查看容器状态 docker-compose ps ``` 4. 📱 查看日志然后登录微信 ```bash # 查看日志获取登录二维码 docker-compose logs -f xybotv2 ``` 扫描终端显示的二维码完成登录。(如果扫不出来,可以打开链接扫码)。首次登录成功后,需要挂机4小时。之后机器人就会自动开始正常运行。 5. ⚙️ 配置文件修改 ```bash # 查看数据卷位置 docker volume inspect xybotv2 # 编辑对应目录下的配置文件 xybotv2-volumes-dir/_data/main_config.toml xybotv2-volumes-dir/_data/plugins/all_in_one_config.toml ``` 修改配置后需要重启容器使配置生效: ```bash docker-compose restart xybotv2 ``` > 如果是修改插件配置则可使用热加载、热卸载、热重载指令,不用重启机器人。 6. 🔄 访问Web界面 Web界面可通过以下地址访问: ``` http://服务器IP地址:9999 ``` Web界面可通过以下地址访问: ``` http://服务器IP地址:9999 ``` ## ❓ 常见问题 1. 🔌 Redis 连接失败 - 检查 Redis 服务是否正常运行 - 确认 main_config.toml 中的 redis-host 配置是否正确 2. ⚠️ 配置文件修改未生效 - 重启容器: `docker-compose restart xybotv2` - 检查配置文件权限是否正确 3. 📝 日志查看 ```bash # 查看实时日志 docker-compose logs -f xybotv2 # 查看最近100行日志 docker-compose logs --tail=100 xybotv2 ``` 4. 与网络相关的报错 - 检查网络连接,是否能ping通微信服务器 - 尝试关闭代理软件,尝试重启电脑 - 尝试重启XYBot和Redis - 如是Docker部署,检查Docker容器网络是否能连接到微信服务器和 Redis 数据库 5. `正在运行`相关的报错 - 将占用9000端口的进程强制结束 6. 🌐 无法访问Web界面 - 确保9999端口已在防火墙中开放 - 检查docker-compose.yml中的端口映射配置 ================================================ FILE: docs/zh_cn/Linux部署.md ================================================ #### 1. 🔧 环境准备 ```bash # Ubuntu/Debian sudo apt update sudo apt install python3.11 python3.11-venv redis-server ffmpeg # CentOS/RHEL sudo yum install epel-release # 如果需要EPEL仓库 sudo yum install python3.11 redis ffmpeg sudo systemctl start redis sudo systemctl enable redis # 设置 IMAGEIO_FFMPEG_EXE 环境变量 echo 'export IMAGEIO_FFMPEG_EXE=$(which ffmpeg)' >> ~/.bashrc source ~/.bashrc # 如果使用其他shell(如zsh),则需要: # echo 'export IMAGEIO_FFMPEG_EXE=$(which ffmpeg)' >> ~/.zshrc # source ~/.zshrc ``` #### 2. ⬇️ 下载项目 ```bash # 克隆项目 git clone https://github.com/HenryXiaoYang/XYBotV2.git # 小白:直接 Github Download ZIP cd XYBotV2 # 创建虚拟环境 python3.11 -m venv venv source venv/bin/activate # 安装依赖 pip install -r requirements.txt # 安装gunicorn和eventlet pip install gunicorn eventlet # 使用镜像源安装 pip install -r requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple ``` 4. 🚀 启动机器人 ```bash # 确保在虚拟环境中 source venv/bin/activate # 检查Redis服务状态 systemctl status redis # 如果Redis未运行,启动服务 sudo systemctl start redis # 设置Redis开机自启 sudo systemctl enable redis # 验证Redis连接 redis-cli ping # 如果返回PONG表示连接正常 # 启动机器人WebUI python app.py ``` 5. 进入WebUI 访问 `9999` 端口。 默认用户名是`admin`,密码是`admin123` 6. 点击`启动`,账号信息出会出现一个二维码,微信扫码即可。 7. 💻 不需要WebUI的简单启动方式 如果你不需要WebUI界面,可以直接使用bot.py来运行机器人: ```bash # 确保在虚拟环境中 source venv/bin/activate # 直接运行bot.py python bot.py ``` 这种方式不会启动Web界面,机器人核心功能依然正常工作。使用这种方式时: - 二维码会直接显示在终端中 - 所有机器人功能正常可用 ## ❓ 常见问题 1. 与网络相关的报错 - 检查网络连接,是否能ping通微信服务器 - 尝试关闭代理软件,尝试重启电脑 - 尝试重启XYBot和Redis - 如是Docker部署,检查Docker容器网络是否能连接到微信服务器和 Redis 数据库 2. `正在运行`相关的报错 - 将占用9000端口的进程强制结束 3. 🌐 无法访问Web界面 - 确保9999端口已在防火墙中开放 ```bash # Ubuntu/Debian sudo ufw allow 9999 # CentOS sudo firewall-cmd --permanent --add-port=9999/tcp sudo firewall-cmd --reload ``` ================================================ FILE: docs/zh_cn/Windows部署.md ================================================ # 🪟 Windows 部署 ## 1. 🔧 环境准备 - 安装 Python 3.11 (必须是3.11版本): https://www.python.org/downloads/release/python-3119/ - 在安装过程中勾选 "Add Python to PATH" 选项 - 或者手动添加: 1. 右键点击 "此电脑" -> "属性" -> "高级系统设置" -> "环境变量" 2. 在 "系统变量" 中找到 Path,点击 "编辑" 3. 添加 Python 安装目录(如 `C:\Python311`)和 Scripts 目录(如 `C:\Python311\Scripts`) - 安装 ffmpeg: 1. 从 [ffmpeg官网](https://www.ffmpeg.org/download.html) 下载 Windows 版本 2. 解压到合适的目录(如 `C:\ffmpeg`) 3. 添加环境变量: - 右键点击 "此电脑" -> "属性" -> "高级系统设置" -> "环境变量" - 在 "系统变量" 中找到 Path,点击 "编辑" - 添加 ffmpeg 的 bin 目录路径(如 `C:\ffmpeg\bin`) 4. 设置 IMAGEIO_FFMPEG_EXE 环境变量: - 在 "系统变量" 中点击 "新建" - 变量名输入:`IMAGEIO_FFMPEG_EXE` - 变量值输入 ffmpeg.exe 的完整路径(如 `C:\ffmpeg\bin\ffmpeg.exe`) 5. 重启命令提示符或 PowerShell 使环境变量生效 6. 验证安装: ```bash ffmpeg -version ``` - 安装 Redis: - 从 [Redis](https://github.com/tporadowski/redis/releases/tag/v5.0.14.1) 下载最新版本 (目前是7.4.2) - 下载并解压 `Redis-x64-5.0.14.1.zip` - 在命令行执行: ```bash # 进入目录 cd Redis-x64-5.0.14.1 # 启动Redis服务 start redis-server.exe ``` ## 2. ⬇️ 下载项目 ```bash # 克隆项目 git clone https://github.com/HenryXiaoYang/XYBotV2.git # 小白:直接 Github Download ZIP cd XYBotV2 # 创建虚拟环境 python -m venv venv .\venv\Scripts\activate # 安装依赖 pip install -r requirements.txt # 安装gunicorn和eventlet pip install gunicorn eventlet # 使用镜像源安装 pip install -r requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple ``` ## 3. 🚀 启动机器人 ```bash # 确保Redis服务已启动 redis-cli ping # 如果返回PONG则表示Redis正常运行 # 启动WebUI python app.py ``` 5. 进入WebUI 访问 `9999` 端口。 默认用户名是`admin`,密码是`admin123` 6. 点击`启动`,账号信息出会出现一个二维码,微信扫码即可。 7. 💻 不需要WebUI的简单启动方式 如果你不需要WebUI界面,可以直接使用bot.py来运行机器人: ```bash # 直接运行bot.py python bot.py ``` 这种方式不会启动Web界面,机器人核心功能依然正常工作。使用这种方式时: - 二维码会直接显示在终端中 - 所有机器人功能正常可用 ## ❓ 常见问题 1. 与网络相关的报错 - 检查网络连接,是否能ping通微信服务器 - 尝试关闭代理软件,尝试重启电脑 - 尝试重启XYBot和Redis - 如是Docker部署,检查Docker容器网络是否能连接到微信服务器和 Redis 数据库 2. `正在运行`相关的报错 - 将占用9000端口的进程强制结束 3. 🌐 无法访问Web界面 - 确保Windows防火墙允许9999端口通信 1. 打开"控制面板" -> "Windows Defender防火墙" -> "高级设置" 2. 选择"入站规则" -> "新建规则" 3. 规则类型选"端口" -> 指定TCP和9999端口 -> 允许连接 -> 给规则起名并完成 ================================================ FILE: docs/zh_cn/插件开发.md ================================================ # 🧩 XYBotV2 插件开发指南 ## 插件 插件是XYBotV2的扩展,用于实现各种功能。 所有插件都在`plugins`文件夹内。每一个文件夹都是一个插件。一个插件都要包含`main.py`文件,作为插件入口。 而`main.py`里会有继承`PluginBase`的类,用来识别插件,定义插件所需要的方法、装饰器、等等。 插件文件夹里的`__init__.py`文件不是必须的,也可以为空文件。插件当作一个Python包被导入时,`__init__.py` 中的代码会自动执行。 如果要新加插件,可直接把插件文件放入`plugins`文件夹内。 如果你不要什么功能,直接把`plugins`文件夹中对应的文件夹删掉。(没错就是这么简单) ## 示例插件和插件模版 示例插件位于`plugins/ExamplePlugin`。 插件模版在 [XYBotV2PluginTemplate](https://github.com/HenryXiaoYang/XYBotV2PluginTemplate) Github仓库 ## 编写插件 每个插件的`main.py`都需要继承`PluginBase`基类,并定义基本信息。以下是一个基本的插件示例: ```python # main.py from utils.plugin_base import PluginBase class ExamplePlugin(PluginBase): description = "示例插件" author = "HenryXiaoYang" version = "1.0.0" ``` 这是创建插件的最基本结构: 1. 继承 `PluginBase` 类以获取基本功能 2. 设置插件的描述信息、作者和版本号 3. 之后可以在这个类中添加各种事件处理函数 ```python # ... 接上面 ... # # 同步初始化 def __init__(self): super().__init__() # 在这里初始化插件需要的变量 self.data = {} # 异步初始化 async def async_init(self): # 在这里执行需要的异步初始化操作 pass ``` - `__init__`: 初始化函数,用于设置插件的初始状态 - `async_init`: 异步初始化函数,用于执行插件的异步初始化操作 ```python # ... 接上面 ... # @on_text_message # 处理文本消息 async def on_text(self, bot: WechatAPIClient, message: dict): pass ``` - `@on_text_message`: 处理文本消息的事件,所装饰的函数必须是异步函数。 ## 事件类型列表 | 事件类型 | 装饰器 | 触发条件 | |------|----------------------|-----------| | 文本消息 | `@on_text_message` | 收到任意文本消息 | | 图片消息 | `@on_image_message` | 收到图片消息 | | 语音消息 | `@on_voice_message` | 收到语音消息 | | 文件消息 | `@on_file_message` | 收到文件消息 | | 引用消息 | `@on_quote_message` | 收到引用回复消息 | | @消息 | `@on_at_message` | 收到包含@的群消息 | | 视频消息 | `@on_video_message` | 收到视频消息 | | 好友请求 | `@on_friend_request` | 收到新的好友请求 | | 拍一拍 | `@on_pat_message` | 收到拍一拍消息 | ## 消息事件处理函数 ### 优先级机制 优先级机制用于控制多个插件处理同一事件时的执行顺序。 #### 优先级范围 - 优先级数值范围为0-99 - 数值越大,优先级越高 - 默认优先级为50 #### 设置方式 在事件装饰器中通过priority参数设置: ```python # ... 接上面 ... # @on_text_message(priority=80) # 设置较高优先级 async def handle_important(self, bot, message): # 优先处理重要消息 pass @on_text_message(priority=20) # 设置较低优先级 async def handle_normal(self, bot, message): # 后处理普通消息 pass ``` #### 执行顺序 1. 按优先级从高到低依次执行 2. 同优先级的处理函数按注册顺序执行 ### 阻塞机制 阻塞机制用于控制是否继续执行后续的事件处理函数。 #### 基本概念 - 通过插件函数返回值来控制是否继续执行后续处理 - 返回`True`表示继续执行后续处理 - 返回`False`表示处理完成并阻止后续执行 #### 设置方式 ```python @on_text_message async def handle_sensitive(self, bot, message): if "敏感词" in message["Content"]: await bot.send_text(message["FromWxid"], "检测到敏感内容") return False # 返回False表示处理完成并阻止后续执行 return True # 返回False表示继续执行后续处理 @on_text_message async def handle_normal(self, bot, message): # 普通消息处理 pass # 没有返回,默认继续执行 ``` #### 注意事项 1. 合理使用阻塞机制,避免不必要的阻塞 2. 高优先级的阻塞会影响所有低优先级的处理函数 ### 风控保护机制 风控保护机制用于保护机器人账号安全,防止触发微信的安全检测。本机器人的风控保护非常轻量,*不保证*机器人完全不会被风控。 1. **新设备登录限制** - 新设备登录后4小时内不可处理消息,不可调用函数,不可发送消息。只维持自动心跳和接受消息。 2. **消息发送频率** - 消息发送内置了队列,每秒只发一条消息。 ### 异步处理 XYBot整个项目使用asyncio进行异步处理(个别地方除外),所有插件函数必须是异步函数。 使用阻塞函数会导致主进程阻塞,无法接受消息,无法发送消息,无法调用函数。 如果需要使用阻塞函数,请使用`asyncio.run_in_executor`将其转换为异步函数。 ### 资源管理 XYBot使用`loguru`进行日志管理,所有日志都会输出到`logs/xybot.log`文件中。 请将插件会使用到的静态资源存放到`resources`文件夹中。 请将临时会产生的文件存放到`resources/cache`文件夹中。 ## 消息对象结构 ### 文本消息示例 ```python { "MsgId": 123456789, # 消息唯一标识(可用于撤回消息) "ToWxid": "wxid_00000000000000", # 接收者微信ID(通常是机器人自身ID) "MsgType": 1, # 消息类型(1表示文本消息) "Content": "@机器人\u2005", # 消息内容 "Status": 3, # 消息状态码(3表示正常消息) "ImgStatus": 1, # 图片状态 "ImgBuf": { # 图片缓冲区(通常为空) "iLen": 0 }, "CreateTime": 1739878408, # 消息创建时间戳(秒级) "MsgSource": "...", # 消息源数据(XML格式,包含@列表等信息) "PushContent": "机器人在群聊中@了你。", # 系统推送提示内容 "NewMsgId": 1145141919810, # 消息ID(可用于撤回消息) "MsgSeq": 114514, # 消息序列(可用于撤回消息) "FromWxid": "123456789@chatroom", # 消息来源ID(可以是个人wxid或群聊ID) "IsGroup": True, # 是否来自群聊 "SenderWxid": "wxid_11111111111111", # 实际发送人微信ID "Ats": ["wxid_00000000000000"] # 被@的用户列表 } ``` ### 被@消息示例 ```python { "MsgId": 123456789, # 消息唯一标识(可用于撤回消息) "ToWxid": "wxid_00000000000000", # 接收者微信ID(通常是机器人自身ID) "MsgType": 1, # 消息类型(1表示文本消息) "Content": "@机器人\u2005", # 消息内容(\u2005是特殊空格符) "Status": 3, # 消息状态码(3表示正常消息) "ImgStatus": 1, # 图片状态 "ImgBuf": { # 图片缓冲区(通常为空) "iLen": 0 }, "CreateTime": 1739878408, # 消息创建时间戳(秒级) "MsgSource": "...", # 消息源数据(XML格式,包含@列表等信息) "PushContent": "机器人在群聊中@了你。", # 系统推送提示内容 "NewMsgId": 1145141919810, # 新消息ID(可用于撤回消息) "MsgSeq": 114514, # 消息序列号(可用于撤回消息) "FromWxid": "123456789@chatroom", # 消息来源ID(可以是个人wxid或群聊ID) "IsGroup": True, # 是否群聊消息 "SenderWxid": "wxid_11111111111111", # 实际发送人微信ID "Ats": ["wxid_00000000000000"] # 被@的用户列表(包含机器人自身ID) } ``` ### 图片消息示例 ```python { "MsgId": 123456789, # 消息唯一标识(可用于撤回消息) "ToWxid": "wxid_00000000000000", # 接收者微信ID(通常是机器人自身ID) "MsgType": 3, # 消息类型(3表示图片消息) "Content": "/9j/4AAQSkZJ...base64content", # 图片的Base64编码内容 "Status": 3, # 消息状态码(3表示正常消息) "ImgStatus": 2, # 图片状态(2表示图片已下载) "ImgBuf": { # 图片缓冲区(通常为空) "iLen": 0 }, "CreateTime": 1739879142, # 消息创建时间戳(秒级) "MsgSource": "...", # 消息源数据(XML格式,包含图片相关信息) "PushContent": "XYBot : [图片]", # 系统推送提示内容 "NewMsgId": 1145141919810, # 新消息ID(可用于撤回消息) "MsgSeq": 114514, # 消息序列号(可用于撤回消息) "FromWxid": "wxid_11111111111111", # 消息发送者的微信ID "SenderWxid": "wxid_11111111111111", # 实际发送人微信ID(私聊时与FromWxid相同) "IsGroup": False # 是否群聊消息(这里是私聊) } ``` ### 语音消息示例 ```python { "MsgId": 123456789, # 消息唯一标识(可用于撤回消息) "ToWxid": "wxid_00000000000000", # 接收者微信ID(通常是机器人自身ID) "MsgType": 34, # 消息类型(34表示语音消息) "Content": b"VoiceData...", # 语音数据内容(bytes类型) "Status": 3, # 消息状态码(3表示正常消息) "ImgStatus": 1, # 语音状态 "ImgBuf": { # 语音数据缓冲区 "iLen": 1024, "buffer": "AudioData..." # 语音数据内容(base64),群聊中的语音消息这个字段为空 }, "CreateTime": 1739879399, # 消息创建时间戳(秒级) "MsgSource": "...", # 消息源数据(XML格式) "PushContent": "XYBot : [语音]", # 系统推送提示内容 "NewMsgId": 1145141919810, # 新消息ID(可用于撤回消息) "MsgSeq": 114514, # 消息序列号(可用于撤回消息) "FromWxid": "wxid_11111111111111", # 消息发送者的微信ID "SenderWxid": "wxid_11111111111111", # 实际发送人微信ID(私聊时与FromWxid相同) "IsGroup": False # 是否群聊消息(这里是私聊) } ``` ### 文件消息示例 ```python { "MsgId": 123456789, # 消息唯一标识(可用于撤回消息) "ToWxid": "wxid_00000000000000", # 接收者微信ID(通常是机器人自身ID) "MsgType": 49, # 消息类型(49表示XML消息) "Content": "...", # 文件信息的XML内容 "Status": 3, # 消息状态码(3表示正常消息) "ImgStatus": 1, # 文件状态 "ImgBuf": { # 文件数据缓冲区(通常为空) "iLen": 0 }, "CreateTime": 1739879893, # 消息创建时间戳(秒级) "MsgSource": "...", # 消息源数据(XML格式) "PushContent": "XYBot : [文件]example.txt", # 系统推送提示内容 "NewMsgId": 1145141919810, # 新消息ID(可用于撤回消息) "MsgSeq": 114514, # 消息序列号(可用于撤回消息) "FromWxid": "wxid_11111111111111", # 消息发送者的微信ID "SenderWxid": "wxid_11111111111111", # 实际发送人微信ID(私聊时与FromWxid相同) "IsGroup": False, # 是否群聊消息(这里是私聊) "Filename": "example.txt", # 文件名 "FileExtend": "txt", # 文件扩展名 "File": "FileData..." # 文件数据内容(base64编码) } ``` ### 引用消息示例 这个装饰器还不是很完善。 ```python { "MsgId": 123456789, # 消息唯一标识(可用于撤回消息) "ToWxid": "wxid_00000000000000", # 接收者微信ID(通常是机器人自身ID) "MsgType": 49, # 消息类型(49表示引用消息) "Content": "回复的文本内容", # 回复消息的文本内容 "Status": 3, # 消息状态码(3表示正常消息) "ImgStatus": 1, # 消息状态 "ImgBuf": { # 数据缓冲区(通常为空) "iLen": 0 }, "CreateTime": 1739880113, # 消息创建时间戳(秒级) "MsgSource": "...", # 消息源数据(XML格式) "PushContent": "回复的文本内容", # 系统推送提示内容 "NewMsgId": 1145141919810, # 新消息ID(可用于撤回消息) "MsgSeq": 114514, # 消息序列号(可用于撤回消息) "FromWxid": "wxid_11111111111111", # 消息发送者的微信ID "SenderWxid": "wxid_11111111111111", # 实际发送人微信ID "IsGroup": False, # 是否群聊消息 "Quote": { # 被引用的原始消息信息 "MsgType": 1, # 原始消息类型(1表示文本消息) "NewMsgId": "1145141919811", # 原始消息的ID "ToWxid": "wxid_00000000000000", # 原始消息接收者ID "FromWxid": "wxid_11111111111111", # 原始消息发送者ID "Nickname": "XYBot", # 原始消息发送者昵称 "MsgSource": "...", # 原始消息源数据 "Content": "引用的消息内容", # 引用的消息内容 "Createtime": "1739879158" # 原始消息创建时间 } } ``` ### 视频消息示例 ```python { "MsgId": 123456789, # 消息唯一标识(可用于撤回消息) "ToWxid": "wxid_00000000000000", # 接收者微信ID(通常是机器人自身ID) "MsgType": 43, # 消息类型(43表示视频消息) "Content": "\n\n\t", # 视频信息的XML内容 "Status": 3, # 消息状态码(3表示正常消息) "ImgStatus": 1, # 视频状态 "ImgBuf": { # 视频数据缓冲区(通常为空) "iLen": 0 }, "CreateTime": 1739880402, # 消息创建时间戳(秒级) "MsgSource": "...", # 消息源数据(XML格式) "NewMsgId": 1145141919810, # 新消息ID(可用于撤回消息) "MsgSeq": 114514, # 消息序列号(可用于撤回消息) "FromWxid": "wxid_11111111111111", # 消息发送者的微信ID "SenderWxid": "wxid_11111111111111", # 实际发送人微信ID "IsGroup": False, # 是否群聊消息(这里是私聊) "Video": "VideoData..." # 视频数据内容(base64编码) } ``` ### 好友请求消息示例 之后写 ### 拍一拍消息示例 ```python { "MsgId": 123456789, # 消息唯一标识(可用于撤回消息) "ToWxid": "wxid_00000000000000", # 接收者微信ID(通常是机器人自身ID) "MsgType": 10002, # 消息类型(10002表示拍一拍消息) "Content": "\n\n\n wxid_11111111111111\n 11111111111@chatroom\n wxid_00000000000000\n \n \n\n", # 拍一拍消息的XML内容 "Status": 4, # 消息状态码 "ImgStatus": 1, # 消息状态 "ImgBuf": { # 数据缓冲区(通常为空) "iLen": 0 }, "CreateTime": 1739880846, # 消息创建时间戳(秒级) "MsgSource": "...", # 消息源数据(XML格式) "NewMsgId": 1145141919810, # 新消息ID(可用于撤回消息) "MsgSeq": 114514, # 消息序列号(可用于撤回消息) "FromWxid": "11111111111@chatroom", # 消息来源群聊ID "IsGroup": True, # 是否群聊消息(这里是群聊) "SenderWxid": "11111111111@chatroom", # 群聊ID "Patter": "wxid_11111111111111", # 发起拍一拍的用户ID "Patted": "wxid_00000000000000", # 被拍的用户ID "PatSuffix": "拍了拍我" # 拍一拍的后缀文本 } ``` ## 定时任务装饰器 `@schedule`装饰器用于创建定时执行的任务,支持多种触发方式。 ### 基本用法 ```python from utils.decorators import schedule class TimerPlugin(PluginBase): @schedule('interval', seconds=5) async def periodic_task(self, bot): # 每5秒执行一次 await bot.send_text("example", "定时消息") @schedule('cron', hour=8, minute=30) async def daily_task(self, bot): # 每天早上8:30执行 await bot.send_text("example", "早安") @schedule('date', run_date='2024-12-31 23:59:59') async def one_time_task(self, bot): # 在指定时间执行一次 await bot.send_text("example", "新年快乐") ``` ### 触发器类型 1. **interval - 间隔触发** 对于参数请查看[apscheduler.triggers.interval](https://apscheduler.readthedocs.io/en/3.x/modules/triggers/interval.html) ```python @schedule('interval', seconds=30) async def task(self, bot): # 每1小时30分钟执行一次 pass ``` 2. **cron - 定时触发** 参数请查看[apscheduler.triggers.cron](https://apscheduler.readthedocs.io/en/3.x/modules/triggers/cron.html) ```python @schedule('cron', day_of_week='mon-fri', hour='9-17') async def work_time_task(self, bot): # 工作日9点到17点每小时执行 pass ``` 3. **date - 指定日期触发** 参数请查看[apscheduler.triggers.date](https://apscheduler.readthedocs.io/en/3.x/modules/triggers/date.html) ```python @schedule('date', run_date='2024-01-01 00:00:00') async def new_year_task(self, bot): # 在2024年新年时执行一次 pass ``` ### 高级用法 1. **组合使用多个定时任务** ```python class ComplexTimer(PluginBase): @schedule('cron', hour=8) async def morning(self, bot): await bot.send_text("example", "早安") @schedule('cron', hour=23) async def night(self, bot): await bot.send_text("example", "晚安") @schedule('interval', minutes=30) async def check_status(self, bot): # 每30分钟检查一次状态 pass ``` ## WechatAPIClient 机器人的函数 在事件函数中,可以调用`bot`对象的函数。 可在 [API文档](WechatAPIClient/index.html) 获取详细接口说明。 ================================================ FILE: docs/zh_cn/配置文件.md ================================================ # XYBotV2 配置文件 XYBotV2 有两个主要配置文件需要修改: - `main_config.toml`:主配置文件 - `plugins/all_in_one_config.toml`:插件配置文件 ## 不同系统下的配置文件修改方法 ### Windows 系统 1. 直接使用记事本或其他文本编辑器(推荐使用 VSCode、Sublime Text 等)打开配置文件: ```bash # 主配置文件位置 XYBotV2/main_config.toml # 插件配置文件位置 XYBotV2/plugins/all_in_one_config.toml ``` ### Linux 系统 1. 使用命令行文本编辑器(如 vim、nano)编辑: ```bash # 使用 vim 编辑 vim main_config.toml vim plugins/all_in_one_config.toml # 或使用 nano 编辑 nano main_config.toml nano plugins/all_in_one_config.toml ``` 2. 也可以使用图形界面编辑器(如果是桌面环境): ```bash # 使用 gedit(GNOME) gedit main_config.toml gedit plugins/all_in_one_config.toml # 使用 kate(KDE) kate main_config.toml kate plugins/all_in_one_config.toml ``` ### Docker 环境 1. 首先找到数据卷位置: ```bash # 查看数据卷位置 docker volume inspect xybotv2 ``` 2. 进入数据卷目录编辑配置文件: ```bash # 配置文件通常位于: xybotv2-volumes-dir/_data/main_config.toml xybotv2-volumes-dir/_data/plugins/all_in_one_config.toml ``` 3. 修改后重启容器使配置生效: ```bash docker-compose restart xybotv2 ``` ## 配置文件修改后生效方式 1. 主配置文件(`main_config.toml`)修改后: - 需要重启机器人才能生效 - Windows/Linux:按 `Ctrl+C` 停止后重新运行 `python main.py` - Docker:执行 `docker-compose restart xybotv2` 2. 插件配置文件(`plugins/all_in_one_config.toml`)修改后: - 可以使用热重载命令,无需重启机器人 - 在聊天中发送以下命令之一(需要机器人管理员权限): - `重载插件 插件名` - `重载所有插件` - 也可以重启机器人来生效 ## 注意事项 1. 确保配置文件格式正确: - 使用 `UTF-8` 编码 - 遵循 `TOML` 格式规范 - 修改后检查是否有语法错误 2. 权限问题: - Linux/Docker 环境下确保有正确的读写权限 - 如遇权限问题,可使用 sudo 或调整文件权限: ```bash sudo chmod 644 main_config.toml sudo chmod 644 plugins/all_in_one_config.toml ``` 3. 管理员权限说明: - 可在主配置文件中设置管理员 4. Docker 环境特别说明: - 配置文件位于数据卷中,修改后会持久保存 - 重建容器不会影响配置文件 - 确保数据卷正确挂载 ## 配置说明 # main_config.toml 配置说明 ```toml [WechatAPIServer] port = 9000 # WechatAPI服务器端口,默认9000,如有冲突可修改 mode = "release" # 运行模式:release(生产环境),debug(调试模式) redis-host = "127.0.0.1" # Redis服务器地址,本地使用127.0.0.1 redis-port = 6379 # Redis端口,默认6379 redis-password = "" # Redis密码,如果有设置密码则填写 redis-db = 0 # Redis数据库编号,默认0 # XYBot 核心设置 [XYBot] version = "v1.0.0" # 版本号,请勿修改 ignore-protection = false # 是否忽略风控保护机制,建议保持false # SQLite数据库地址,一般无需修改 XYBotDB-url = "sqlite:///database/xybot.db" msgDB-url = "sqlite+aiosqlite:///database/message.db" keyvalDB-url = "sqlite+aiosqlite:///database/keyval.db" # 管理员设置 admins = ["admin-wxid", "admin-wxid"] # 管理员的wxid列表,可从消息日志中获取 disabled-plugins = ["ExamplePlugin", "TencentLke"] # 禁用的插件列表,不需要的插件名称填在这里 timezone = "Asia/Shanghai" # 时区设置,中国用户使用 Asia/Shanghai # 实验性功能,如果main_config.toml配置改动,或者plugins文件夹有改动,自动重启。可以在开发时使用,不建议在生产环境使用。 auto-restart = false # 仅建议在开发时启用,生产环境保持false # 消息过滤设置 ignore-mode = "None" # 消息处理模式: # "None" - 处理所有消息 # "Whitelist" - 仅处理白名单消息 # "Blacklist" - 屏蔽黑名单消息 whitelist = [# 白名单列表 "wxid_1", # 个人用户微信ID "wxid_2", "111@chatroom", # 群聊ID "222@chatroom" ] blacklist = [# 黑名单列表 "wxid_3", # 个人用户微信ID "wxid_4", "333@chatroom", # 群聊ID "444@chatroom" ] ``` ## 说明 1. **管理员设置** - 管理员ID获取方法: 1. 先启动机器人 2. 私聊机器人任意消息 3. 在日志中找到自己的 `wxid` 2. **消息过滤模式** - `None` 模式:处理所有消息 - `Whitelist` 模式:仅处理白名单中的用户/群消息 - `Blacklist` 模式:屏蔽黑名单中的用户/群消息 3. **数据安全** - 建议定期备份数据库文件(`xybot.db`) - 请勿泄露配置文件中的敏感信息(如 `API` 密钥) ## 插件配置 每个插件现在都在单独的文件夹中,都包含 `config.toml` 插件配置文件。 ## 获取天气 [GetWeather] 用于查询城市天气信息的功能模块。 ```toml enable = true # 是否启用此功能 command-format = """⚙️获取天气: # 支持的命令格式说明 天气 城市名 天气城市名 城市名天气 城市名 天气""" api-key = "api-key" # 和风天气API密钥 # 申请方法: # 1. 访问 https://dev.qweather.com/ # 2. 注册账号并选择免费订阅 # 3. 获取 Private KEY(注意不是 Public ID) ``` ================================================ FILE: main_config.toml ================================================ [WechatAPIServer] port = 9000 # WechatAPI服务器端口,默认9000,如有冲突可修改 mode = "release" # 运行模式:release(生产环境),debug(调试模式) redis-host = "127.0.0.1" # Redis服务器地址,本地使用127.0.0.1 redis-port = 6379 # Redis端口,默认6379 redis-password = "" # Redis密码,如果有设置密码则填写 redis-db = 0 # Redis数据库编号,默认0 # XYBot 核心设置 [XYBot] version = "v1.0.0" # 版本号,请勿修改 ignore-protection = false # 是否忽略风控保护机制,建议保持false # SQLite数据库地址,一般无需修改 XYBotDB-url = "sqlite:///database/xybot.db" msgDB-url = "sqlite+aiosqlite:///database/message.db" keyvalDB-url = "sqlite+aiosqlite:///database/keyval.db" # 管理员设置 admins = ["admin-wxid", "admin-wxid"] # 管理员的wxid列表,可从消息日志中获取 disabled-plugins = ["ExamplePlugin", "TencentLke", "DailyBot"] # 禁用的插件列表,不需要的插件名称填在这里 timezone = "Asia/Shanghai" # 时区设置,中国用户使用 Asia/Shanghai # 实验性功能,如果main_config.toml配置改动,或者plugins文件夹有改动,自动重启。可以在开发时使用,不建议在生产环境使用。 auto-restart = false # 仅建议在开发时启用,生产环境保持false # 消息过滤设置 ignore-mode = "None" # 消息处理模式: # "None" - 处理所有消息 # "Whitelist" - 仅处理白名单消息 # "Blacklist" - 屏蔽黑名单消息 whitelist = [# 白名单列表 "wxid_1", # 个人用户微信ID "wxid_2", "111@chatroom", # 群聊ID "222@chatroom" ] blacklist = [# 黑名单列表 "wxid_3", # 个人用户微信ID "wxid_4", "333@chatroom", # 群聊ID "444@chatroom" ] [WebUI] admin-username = "admin" # 管理员账号 admin-password = "admin123" # 管理员密码(注意安全风险!) session-timeout = 30 # 会话超时时间(分钟) flask-secret-key = "" # 如为空,会覆盖环境变量。如果覆盖环境变量也是空的则默认用"HenryXiaoYang_XYBotV2" debug = false ================================================ FILE: plugins/AdminPoint/__init__.py ================================================ ================================================ FILE: plugins/AdminPoint/config.toml ================================================ [AdminPoint] enable = true command-format = """⚙️管理积分 ➕积分: 加积分 积分 wxid/@用户 ➖积分: 减积分 积分 wxid/@用户 🔢设置积分: 设置积分 积分 wxid/@用户""" ================================================ FILE: plugins/AdminPoint/main.py ================================================ import tomllib from WechatAPI import WechatAPIClient from database.XYBotDB import XYBotDB from utils.decorators import * from utils.plugin_base import PluginBase class AdminPoint(PluginBase): description = "管理积分" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/AdminPoint/config.toml", "rb") as f: plugin_config = tomllib.load(f) with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) config = plugin_config["AdminPoint"] main_config = main_config["XYBot"] self.enable = config["enable"] self.command_format = config["command-format"] self.admins = main_config["admins"] self.db = XYBotDB() @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if not len(command) or command[0] not in ["加积分", "减积分", "设置积分"]: return sender_wxid = message["SenderWxid"] if sender_wxid not in self.admins: await bot.send_text_message(message["FromWxid"], "-----XYBot-----\n❌你配用这个指令吗?😡") return elif len(command) < 3 or not command[1].isdigit(): await bot.send_text_message(message["FromWxid"], f"-----XYBot-----\n{self.command_format}") return if command[0] == "加积分": if command[2].startswith("@") and len(message["Ats"]) == 1: # 判断是@还是wxid change_wxid = message["Ats"][0] elif "@" not in " ".join(command[2:]): change_wxid = command[2] else: await bot.send_text_message(message["FromWxid"], "-----XYBot-----\n❌请不要手动@!") return change_point = int(command[1]) self.db.add_points(change_wxid, change_point) nickname = await bot.get_nickname(change_wxid) new_point = self.db.get_points(change_wxid) output = ( f"-----XYBot-----\n" f"成功功给 {change_wxid} {nickname if nickname else ''} 加了 {change_point} 点积分\n" f"他现在有 {new_point} 点积分" ) await bot.send_text_message(message["FromWxid"], output) elif command[0] == "减积分": if command[2].startswith("@") and len(message["Ats"]) == 1: # 判断是@还是wxid change_wxid = message["Ats"][0] elif "@" not in " ".join(command[2:]): change_wxid = command[2] else: await bot.send_text_message(message["FromWxid"], "-----XYBot-----\n❌请不要手动@!") return change_point = int(command[1]) self.db.add_points(change_wxid, -change_point) nickname = await bot.get_nickname(change_wxid) new_point = self.db.get_points(change_wxid) output = ( f"-----XYBot-----\n" f"成功功给 {nickname if nickname else ''} {change_wxid} 减了 {change_point} 点积分\n" f"他现在有 {new_point} 点积分" ) await bot.send_text_message(message["FromWxid"], output) elif command[0] == "设置积分": if command[2].startswith("@") and len(message["Ats"]) == 1: # 判断是@还是wxid change_wxid = message["Ats"][0] elif "@" not in " ".join(command[2:]): change_wxid = command[2] else: await bot.send_text_message(message["FromWxid"], "-----XYBot-----\n❌请不要手动@!") return change_point = int(command[1]) self.db.set_points(change_wxid, change_point) nickname = await bot.get_nickname(change_wxid) output = ( f"-----XYBot-----\n" f"成功功将 {nickname if nickname else ''} {change_wxid} 的积分设置为 {change_point}" ) await bot.send_text_message(message["FromWxid"], output) ================================================ FILE: plugins/AdminSigninReset/__init__.py ================================================ ================================================ FILE: plugins/AdminSigninReset/config.toml ================================================ [AdminSignInReset] enable = true command = ["重置签到", "重置签到状态"] ================================================ FILE: plugins/AdminSigninReset/main.py ================================================ import tomllib from WechatAPI import WechatAPIClient from database.XYBotDB import XYBotDB from utils.decorators import * from utils.plugin_base import PluginBase class AdminSignInReset(PluginBase): description = "重置签到状态" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/AdminSigninReset/config.toml", "rb") as f: plugin_config = tomllib.load(f) with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) config = plugin_config["AdminSignInReset"] main_config = main_config["XYBot"] self.enable = config["enable"] self.command = config["command"] self.admins = main_config["admins"] self.db = XYBotDB() @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if not len(command) or command[0] not in self.command: return sender_wxid = message["SenderWxid"] if sender_wxid not in self.admins: await bot.send_text_message(message["FromWxid"], "-----XYBot-----\n❌你配用这个指令吗?😡") return self.db.reset_all_signin_stat() await bot.send_text_message(message["FromWxid"], "-----XYBot-----\n成功重置签到状态!") ================================================ FILE: plugins/AdminWhitelist/__init__.py ================================================ ================================================ FILE: plugins/AdminWhitelist/config.toml ================================================ [AdminWhitelist] enable = true command-format = """⚙️管理白名单 ➕白名单: 添加白名单 wxid/@用户 ➖白名单: 删除白名单 wxid/@用户 📋白名单列表: 白名单列表""" ================================================ FILE: plugins/AdminWhitelist/main.py ================================================ import tomllib from WechatAPI import WechatAPIClient from database.XYBotDB import XYBotDB from utils.decorators import * from utils.plugin_base import PluginBase class AdminWhitelist(PluginBase): description = "管理白名单" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/AdminWhitelist/config.toml", "rb") as f: plugin_config = tomllib.load(f) with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) config = plugin_config["AdminWhitelist"] main_config = main_config["XYBot"] self.enable = config["enable"] self.command_format = config["command-format"] self.admins = main_config["admins"] self.db = XYBotDB() @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if not len(command) or command[0] not in ["添加白名单", "移除白名单", "白名单列表"]: return sender_wxid = message["SenderWxid"] if sender_wxid not in self.admins: await bot.send_text_message(message["FromWxid"], "-----XYBot-----\n❌你配用这个指令吗?😡") return if command[0] == "添加白名单": if len(command) < 2: await bot.send_text_message(message["FromWxid"], self.command_format) return if command[1].startswith("@") and len(message["Ats"]) == 1: # 判断是@还是wxid change_wxid = message["Ats"][0] elif "@" not in " ".join(command[1:]): change_wxid = command[1] else: await bot.send_text_message(message["FromWxid"], "-----XYBot-----\n❌请不要手动@!") return self.db.set_whitelist(change_wxid, True) nickname = await bot.get_nickname(change_wxid) await bot.send_text_message(message["FromWxid"], f"-----XYBot-----\n成功添加 {nickname if nickname else ''} {change_wxid} 到白名单") elif command[0] == "移除白名单": if len(command) < 2: await bot.send_text_message(message["FromWxid"], self.command_format) return if command[1].startswith("@") and len(message["Ats"]) == 1: # 判断是@还是wxid change_wxid = message["Ats"][0] elif "@" not in " ".join(command[1:]): change_wxid = command[1] else: await bot.send_text_message(message["FromWxid"], "-----XYBot-----\n❌请不要手动@!") return self.db.set_whitelist(change_wxid, False) nickname = await bot.get_nickname(change_wxid) await bot.send_text_message(message["FromWxid"], f"-----XYBot-----\n成功把 {nickname if nickname else ''} {change_wxid} 移出白名单!") elif command[0] == "白名单列表": whitelist = self.db.get_whitelist_list() whitelist = "\n".join([f"{wxid} {await bot.get_nickname(wxid)}" for wxid in whitelist]) await bot.send_text_message(message["FromWxid"], f"-----XYBot-----\n白名单列表:\n{whitelist}") else: await bot.send_text_message(message["FromWxid"], self.command_format) return ================================================ FILE: plugins/BotStatus/__init__.py ================================================ ================================================ FILE: plugins/BotStatus/config.toml ================================================ [BotStatus] enable = true command = ["status", "bot", "机器人状态", "状态"] status-message = "XYBot Running! 😊" ================================================ FILE: plugins/BotStatus/main.py ================================================ import re import tomllib from WechatAPI import WechatAPIClient from utils.decorators import * from utils.plugin_base import PluginBase class BotStatus(PluginBase): description = "机器人状态" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/BotStatus/config.toml", "rb") as f: plugin_config = tomllib.load(f) with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) config = plugin_config["BotStatus"] main_config = main_config["XYBot"] self.enable = config["enable"] self.command = config["command"] self.version = main_config["version"] self.status_message = config["status-message"] @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if not len(command) or command[0] not in self.command: return out_message = (f"{self.status_message}\n" f"当前版本: {self.version}\n" "项目地址:https://github.com/HenryXiaoYang/XYBotV2\n") await bot.send_text_message(message.get("FromWxid"), out_message) @on_at_message async def handle_at(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = re.split(r'[\s\u2005]+', content) if len(command) < 2 or command[1] not in self.command: return out_message = (f"{self.status_message}\n" f"当前版本: {self.version}\n" "项目地址:https://github.com/HenryXiaoYang/XYBotV2\n") await bot.send_text_message(message.get("FromWxid"), out_message) ================================================ FILE: plugins/DependencyManager/README.md ================================================ # 🔧 依赖包管理器 (DependencyManager) > 🚀 通过微信命令直接管理 Python 依赖包,无需登录服务器! > **本插件是 [XYBotv2](https://github.com/HenryXiaoYang/XYBotv2) 的一个插件。** ## ✨ 功能特点 - 📦 **包管理** - 远程安装、更新、查询和卸载 Python 包 - 🔒 **安全控制** - 仅限管理员使用,防止未授权操作 - 🛡️ **包白名单** - 可选启用允许安装的包列表,提高安全性 - 🔍 **导入检查** - 检查包是否可以成功导入,快速诊断问题 - 📋 **列表展示** - 清晰显示已安装的所有包及其版本 - 📊 **详细输出** - 提供完整的安装/卸载过程信息和错误报告 - ⚙️ **版本控制** - 支持安装特定版本的包,灵活应对兼容性需求 - 💬 **简单易用** - 通过直观的命令快速管理依赖 ## 📋 使用指南 ### 安装包 安装最新版本的包: ``` !pip install 包名 ``` 安装特定版本的包: ``` !pip install 包名==1.2.3 ``` ### 查询包信息 查看已安装包的详细信息: ``` !pip show 包名 ``` ### 列出所有已安装的包 ``` !pip list ``` ### 卸载包 ``` !pip uninstall 包名 ``` ### 检查包是否可以导入 ``` !import 包名 ``` ### 从 GitHub 安装插件 使用 github 唤醒词安装(必需): ``` github https://github.com/用户名/插件名.git ``` 或使用简化格式: ``` github 用户名/插件名 ``` 快捷命令安装 GeminiImage 插件: ``` github gemini ``` 获取 GitHub 安装帮助: ``` github help ``` ### 获取帮助 ``` !pip help ``` 或简单地: ``` !pip ``` ## 🔄 使用流程示例 1. 检查某个包是否已安装:`!pip show numpy` 2. 安装新版本的包:`!pip install pandas` 3. 安装特定版本的包:`!pip install tensorflow==2.9.0` 4. 检查包是否可以导入:`!import tensorflow` 5. 如需更新已安装的包:`!pip install --upgrade pillow` 6. 卸载不再需要的包:`!pip uninstall tensorflow` 7. 安装 GitHub 上的插件(完整 URL):`github https://github.com/NanSsye/GeminiImage.git` 8. 安装 GitHub 上的插件(简化格式):`github NanSsye/GeminiImage` 9. 使用快捷命令安装 GeminiImage 插件:`github gemini` 10. 测试插件是否正常工作:`!test dm` ## ⚙️ 配置说明 在`config.toml`中设置: ```toml [basic] # 是否启用插件 enable = true # 管理员列表,只有这些用户可以使用此插件 # 这里填写管理员的微信ID admin_list = ["wxid_lnbsshdobq7y22", "xianan96928"] # 安全设置 # 是否检查包是否在允许列表中(true/false) check_allowed = false # 允许安装的包列表(如果check_allowed=true) allowed_packages = [ "akshare", "requests", "pillow", "matplotlib", "numpy", "pandas", "lxml", "beautifulsoup4", "aiohttp" ] [commands] # 命令前缀配置 install = "!pip install" show = "!pip show" list = "!pip list" uninstall = "!pip uninstall" # GitHub插件安装命令前缀 github_install = "github" ``` ### 安全设置说明 为了保护服务器安全,您可以启用包白名单功能: 1. 将`check_allowed`设置为`true` 2. 在`allowed_packages`列表中添加允许安装的包名 3. 任何不在列表中的包将被拒绝安装 这可以防止安装潜在有害的包或占用过多系统资源的大型包。 ## 🔒 安全性注意事项 - 仅向可信任的管理员提供访问权限 - 考虑启用包白名单功能,限制可安装的包 - 定期检查已安装的包,确保系统安全 - 此插件可以执行系统命令,请谨慎使用 ## 📊 适用场景 - 远程服务器维护,无需 SSH 登录 - 快速安装新依赖以支持其他插件 - 紧急修复生产环境中的依赖问题 - 检查和诊断包导入失败的问题 - 清理不再需要的包以释放空间 ## 📝 开发日志 - v1.0.0: 初始版本发布,支持基本的包管理功能 ## 👨‍💻 作者 **老夏的金库** ©️ 2024 **开源不易,感谢打赏支持!** ![image](https://github.com/user-attachments/assets/2dde3b46-85a1-4f22-8a54-3928ef59b85f) ## �� 许可证 MIT License ================================================ FILE: plugins/DependencyManager/__init__.py ================================================ ================================================ FILE: plugins/DependencyManager/config.toml ================================================ [basic] # 是否启用插件 enable = true # 安全设置 # 是否检查包是否在允许列表中(true/false) check_allowed = false # 允许安装的包列表(如果check_allowed=true) allowed_packages = [ "akshare", "requests", "pillow", "matplotlib", "numpy", "pandas", "lxml", "beautifulsoup4", "aiohttp" ] [commands] # 命令前缀配置 install = "!pip install" show = "!pip show" list = "!pip list" uninstall = "!pip uninstall" # GitHub插件安装命令前缀 github_install = "github" ================================================ FILE: plugins/DependencyManager/main.py ================================================ """ 依赖包管理插件 - 允许管理员通过微信命令安装Python依赖包和Github插件 作者: 老夏的金库 版本: 1.2.0 """ import importlib import io import os import re import shutil import subprocess import sys import tempfile import tomllib import zipfile import requests from loguru import logger from WechatAPI import WechatAPIClient from utils.decorators import * from utils.plugin_base import PluginBase class DependencyManager(PluginBase): """依赖包管理插件,允许管理员通过微信发送命令来安装/更新/查询Python依赖包和Github插件""" description = "依赖包管理插件" author = "老夏的金库 HenryXiaoYang" version = "1.2.0" # Change Log # 1.2.0 HenryXiaoYang 把调试消息和日志都注释掉了,因为刷屏有点严重 def __init__(self): super().__init__() # 记录插件开始初始化 # logger.info("[DependencyManager] 开始加载插件") # 获取配置文件路径 self.plugin_dir = os.path.dirname(os.path.abspath(__file__)) self.config_path = os.path.join(self.plugin_dir, "config.toml") # 获取主项目根目录 - 使用相对路径 - _data/plugins self.root_dir = os.path.dirname(self.plugin_dir) # 指向_data/plugins目录 # logger.info(f"[DependencyManager] 根目录设置为: {self.root_dir}") # 插件目录就是根目录本身 self.plugins_dir = self.root_dir # logger.info(f"[DependencyManager] 插件目录设置为: {self.plugins_dir}") # 加载配置 self.load_config() # logger.info(f"[DependencyManager] 插件初始化完成, 启用状态: {self.enable}, 优先级: 80") def load_config(self): """加载配置文件""" try: with open("main_config.toml", "rb") as f: config = tomllib.load(f) self.admin_list = config.get("XYBot", {}).get("admins", []) # logger.info(f"[DependencyManager] 尝试从 {self.config_path} 加载配置") with open(self.config_path, "rb") as f: config = tomllib.load(f) # 读取基本配置 basic_config = config.get("basic", {}) self.enable = basic_config.get("enable", False) self.allowed_packages = basic_config.get("allowed_packages", []) self.check_allowed = basic_config.get("check_allowed", False) # 读取命令配置 cmd_config = config.get("commands", {}) self.install_cmd = cmd_config.get("install", "!pip install") self.show_cmd = cmd_config.get("show", "!pip show") self.list_cmd = cmd_config.get("list", "!pip list") self.uninstall_cmd = cmd_config.get("uninstall", "!pip uninstall") # 读取插件安装配置 - 使用唤醒词 self.github_install_prefix = cmd_config.get("github_install", "github") # logger.info(f"[DependencyManager] 配置加载成功") # logger.info(f"[DependencyManager] 启用状态: {self.enable}") # logger.info(f"[DependencyManager] 管理员列表: {self.admin_list}") logger.info(f"[DependencyManager] GitHub前缀: '{self.github_install_prefix}'") except Exception as e: logger.error(f"[DependencyManager] 加载配置失败: {str(e)}") self.enable = False self.admin_list = [] self.allowed_packages = [] self.check_allowed = False self.install_cmd = "!pip install" self.show_cmd = "!pip show" self.list_cmd = "!pip list" self.uninstall_cmd = "!pip uninstall" self.github_install_prefix = "github" @on_text_message(priority=80) async def handle_text_message(self, bot: WechatAPIClient, message: dict): """处理文本消息,检查是否为依赖管理命令""" # 在最开始就记录收到消息,即使未启用也记录,便于调试 # logger.info(f"[DependencyManager] 收到消息调用: {message.get('Content', '')}") if not self.enable: # logger.debug("[DependencyManager] 插件未启用,跳过处理") return True # 插件未启用,允许其他插件处理 # 获取消息内容和发送者 - 修改为使用正确的键名 content = message.get("Content", "").strip() from_user = message.get("SenderWxid", "") conversation_id = message.get("FromWxid", "") # 记录所有消息,用于调试 # logger.info(f"[DependencyManager] 收到消息: '{content}'") # 检查是否为管理员 sender_id = from_user # if not sender_id and "IsGroup" in message and message["IsGroup"]: # 如果是群聊消息,则SenderWxid应该已经包含发送者ID # logger.debug(f"[DependencyManager] 群消息,发送者ID: {sender_id}") # 记录消息处理信息 # logger.info(f"[DependencyManager] 发送者ID: {sender_id}") # logger.info(f"[DependencyManager] 会话ID: {conversation_id}") # logger.info(f"[DependencyManager] GitHub安装前缀: {self.github_install_prefix}") # 检查是否为管理员 if sender_id not in self.admin_list: # logger.info(f"[DependencyManager] 用户 {sender_id} 不在管理员列表中") # logger.info(f"[DependencyManager] 当前管理员列表: {self.admin_list}") return True # 非管理员,允许其他插件处理 # logger.info(f"[DependencyManager] 管理员 {sender_id} 发送命令: {content}") # ====================== 命令处理部分 ====================== # 按照优先级排序,先处理特殊命令,再处理标准命令模式 # 1. 测试命令 - 用于诊断插件是否正常工作 if content == "!test dm": await bot.send_text_message(conversation_id, "✅ DependencyManager插件工作正常!") logger.info("[DependencyManager] 测试命令响应成功") return False # 2. GitHub相关命令处理 - 优先级最高 # 2.1 检查是否明确以GitHub前缀开头 - 要求明确的安装意图 starts_with_prefix = content.lower().startswith(self.github_install_prefix.lower()) logger.info( f"[DependencyManager] 检查是否以'{self.github_install_prefix}'开头: {starts_with_prefix}, 内容: '{content}'") # 2.2 GitHub快捷命令 - GeminiImage特殊处理 if starts_with_prefix and (content.strip().lower() == f"{self.github_install_prefix} gemini" or content.strip().lower() == f"{self.github_install_prefix} geminiimage"): logger.info("[DependencyManager] 检测到GeminiImage快捷安装命令") await bot.send_text_message(conversation_id, "🔄 正在安装GeminiImage插件...") await self._handle_github_install(bot, conversation_id, "https://github.com/NanSsye/GeminiImage.git") logger.info("[DependencyManager] GeminiImage快捷安装完成,阻止后续插件处理") return False # 2.3 GitHub帮助命令 if content.strip().lower() == f"{self.github_install_prefix} help": help_text = f"""📦 GitHub插件安装帮助: 1. 安装GitHub上的插件: {self.github_install_prefix} https://github.com/用户名/插件名.git 2. 例如,安装GeminiImage插件: {self.github_install_prefix} https://github.com/NanSsye/GeminiImage.git 3. 简化格式: {self.github_install_prefix} 用户名/插件名 4. 快捷命令安装GeminiImage: {self.github_install_prefix} gemini 5. 插件会自动被克隆到插件目录并安装依赖 注意: 安装后需要重启机器人以加载新插件。 """ await bot.send_text_message(conversation_id, help_text) logger.info("[DependencyManager] GitHub安装帮助命令响应成功") return False # 2.4 标准GitHub安装命令处理 - 必须以明确的前缀开头 if starts_with_prefix: logger.info(f"[DependencyManager] 检测到GitHub安装命令: {content}") # 获取前缀后面的内容 command_content = content[len(self.github_install_prefix):].strip() logger.info(f"[DependencyManager] 提取的命令内容: '{command_content}'") # 处理快捷命令 - gemini if command_content.lower() == "gemini" or command_content.lower() == "geminiimage": logger.info("[DependencyManager] 检测到GeminiImage快捷安装命令") await self._handle_github_install(bot, conversation_id, "https://github.com/NanSsye/GeminiImage.git") logger.info("[DependencyManager] GeminiImage安装命令处理完成,返回False阻止后续处理") return False # 处理标准GitHub URL elif command_content.startswith("https://github.com") or command_content.startswith("github.com"): logger.info(f"[DependencyManager] 检测到GitHub URL: {command_content}") await self._handle_github_install(bot, conversation_id, command_content) logger.info("[DependencyManager] GitHub URL安装命令处理完成,返回False阻止后续处理") return False # 处理简化格式 - 用户名/仓库名 elif "/" in command_content and not command_content.startswith("!"): # 检查是否符合 用户名/仓库名 格式 if re.match(r'^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$', command_content.strip()): repo_path = command_content.strip() logger.info(f"[DependencyManager] 检测到简化的GitHub路径: {repo_path}") github_url = f"https://github.com/{repo_path}" logger.info(f"[DependencyManager] 构建GitHub URL: {github_url}") await self._handle_github_install(bot, conversation_id, github_url) logger.info("[DependencyManager] 简化GitHub路径安装命令处理完成,返回False阻止后续处理") return False # 格式不正确 else: await bot.send_text_message(conversation_id, f"⚠️ GitHub安装命令格式不正确。正确格式为: \n1. {self.github_install_prefix} https://github.com/用户名/插件名.git\n2. {self.github_install_prefix} 用户名/插件名") logger.info("[DependencyManager] GitHub格式不正确,已发送提示,返回False阻止后续处理") return False # 如果是以GitHub前缀开头但没有匹配到任何处理分支,也阻止后续处理 logger.info("[DependencyManager] 命令以github开头但未匹配任何处理逻辑,默认阻止后续处理") return False # 忽略智能识别GitHub URL的逻辑,必须以明确的前缀开始才处理 # 3. 依赖管理命令 # 3.1 处理安装命令 if content.startswith(self.install_cmd): await self._handle_install(bot, conversation_id, content.replace(self.install_cmd, "").strip()) logger.debug(f"[DependencyManager] 处理安装命令完成,阻止后续插件") return False # 命令已处理,不传递给其他插件 # 3.2 处理查询命令 elif content.startswith(self.show_cmd): await self._handle_show(bot, conversation_id, content.replace(self.show_cmd, "").strip()) logger.debug(f"[DependencyManager] 处理查询命令完成,阻止后续插件") return False # 3.3 处理列表命令 elif content.startswith(self.list_cmd): await self._handle_list(bot, conversation_id) logger.debug(f"[DependencyManager] 处理列表命令完成,阻止后续插件") return False # 3.4 处理卸载命令 elif content.startswith(self.uninstall_cmd): await self._handle_uninstall(bot, conversation_id, content.replace(self.uninstall_cmd, "").strip()) logger.debug(f"[DependencyManager] 处理卸载命令完成,阻止后续插件") return False # 3.5 处理帮助命令 elif content.strip() == "!pip help" or content.strip() == "!pip": await self._send_help(bot, conversation_id) logger.debug(f"[DependencyManager] 处理帮助命令完成,阻止后续插件") return False # 3.6 处理导入检查命令 elif content.startswith("!import"): package = content.replace("!import", "").strip() await self._check_import(bot, conversation_id, package) logger.debug(f"[DependencyManager] 处理导入检查命令完成,阻止后续插件") return False # 不是本插件的命令 logger.debug(f"[DependencyManager] 非依赖管理相关命令,允许其他插件处理") return True # 不是命令,允许其他插件处理 async def _handle_install(self, bot: WechatAPIClient, chat_id: str, package_spec: str): """处理安装依赖包命令""" if not package_spec: await bot.send_text_message(chat_id, "请指定要安装的包,例如: !pip install packagename==1.0.0") return # 检查是否在允许安装的包列表中 base_package = package_spec.split("==")[0].split(">=")[0].split(">")[0].split("<")[0].strip() if self.check_allowed and self.allowed_packages and base_package not in self.allowed_packages: await bot.send_text_message(chat_id, f"⚠️ 安全限制: {base_package} 不在允许安装的包列表中") return await bot.send_text_message(chat_id, f"📦 正在安装: {package_spec}...") try: # 执行pip安装命令 process = subprocess.Popen( [sys.executable, "-m", "pip", "install", package_spec], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) stdout, stderr = process.communicate() if process.returncode == 0: # 安装成功 output = f"✅ 安装成功: {package_spec}\n\n{stdout}" # 如果输出太长,只取前后部分 if len(output) > 1000: output = output[:500] + "\n...\n" + output[-500:] await bot.send_text_message(chat_id, output) else: # 安装失败 error = f"❌ 安装失败: {package_spec}\n\n{stderr}" # 如果输出太长,只取前后部分 if len(error) > 1000: error = error[:500] + "\n...\n" + error[-500:] await bot.send_text_message(chat_id, error) except Exception as e: await bot.send_text_message(chat_id, f"❌ 执行安装命令时出错: {str(e)}") async def _handle_github_install(self, bot: WechatAPIClient, chat_id: str, github_url: str): """处理从Github安装插件的命令""" logger.info(f"[DependencyManager] 开始处理GitHub插件安装,URL: {github_url}") # 处理各种GitHub URL格式 if not github_url: logger.warning("[DependencyManager] GitHub URL为空") await bot.send_text_message(chat_id, "请提供有效的GitHub仓库URL,例如: github https://github.com/用户名/插件名.git") return # 标准化GitHub URL # 处理不包含https://的情况 if not github_url.startswith("http"): if github_url.startswith("github.com"): github_url = "https://" + github_url elif "github.com" in github_url: # 尝试提取用户名/仓库名 match = re.search(r'(?:github\.com[:/])?([^/\s]+/[^/\s]+)(?:\.git)?', github_url) if match: repo_path = match.group(1) github_url = f"https://github.com/{repo_path}" else: github_url = "https://github.com/" + github_url.strip() logger.info(f"[DependencyManager] 标准化后的URL: {github_url}") # 验证URL格式 if not github_url.startswith("https://github.com"): logger.warning(f"[DependencyManager] 无效的GitHub URL: {github_url}") await bot.send_text_message(chat_id, "请提供有效的GitHub仓库URL,例如: github https://github.com/用户名/插件名.git") return # 确保URL以.git结尾 if github_url.endswith(".git"): github_url = github_url[:-4] # 移除.git后缀,为了构建zip下载链接 # 从URL提取插件名称和仓库信息 repo_match = re.search(r'https://github\.com/([^/]+)/([^/]+)$', github_url) if not repo_match: logger.warning(f"[DependencyManager] 无法从URL中提取仓库信息: {github_url}") await bot.send_text_message(chat_id, f"⚠️ 无法从URL中提取仓库信息: {github_url}") return user_name = repo_match.group(1) repo_name = repo_match.group(2) plugin_name = repo_name # 使用相对路径,直接在plugins_dir下创建插件目录 plugin_target_dir = os.path.join(self.plugins_dir, plugin_name) logger.info(f"[DependencyManager] 提取到用户名: {user_name}, 仓库名: {repo_name}") logger.info(f"[DependencyManager] 目标目录: {plugin_target_dir}") # 检查插件目录是否已存在 if os.path.exists(plugin_target_dir): logger.info(f"[DependencyManager] 插件目录已存在,尝试更新") await bot.send_text_message(chat_id, f"⚠️ 插件 {plugin_name} 目录已存在,尝试更新...") try: # 尝试使用git更新现有插件 git_installed = self._check_git_installed() if git_installed: os.chdir(plugin_target_dir) logger.info(f"[DependencyManager] 执行git pull操作于: {plugin_target_dir}") process = subprocess.Popen( ["git", "pull", "origin", "main"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) stdout, stderr = process.communicate() logger.info(f"[DependencyManager] Git pull结果:退出码 {process.returncode}") logger.info(f"[DependencyManager] Stdout: {stdout}") logger.info(f"[DependencyManager] Stderr: {stderr}") if process.returncode == 0: await bot.send_text_message(chat_id, f"✅ 成功更新插件 {plugin_name}!\n\n{stdout}") await self._install_plugin_requirements(bot, chat_id, plugin_target_dir) else: logger.error(f"[DependencyManager] 更新插件失败: {stderr}") await bot.send_text_message(chat_id, f"❌ 更新插件失败: {stderr}") else: # 使用ZIP方式更新 await bot.send_text_message(chat_id, f"⚠️ Git未安装,尝试通过下载ZIP方式更新...") success = await self._download_github_zip(bot, chat_id, user_name, repo_name, plugin_target_dir, is_update=True) if success: await self._install_plugin_requirements(bot, chat_id, plugin_target_dir) except Exception as e: logger.exception(f"[DependencyManager] 更新插件时出错") await bot.send_text_message(chat_id, f"❌ 更新插件时出错: {str(e)}") return # 创建临时目录 with tempfile.TemporaryDirectory() as temp_dir: try: logger.info(f"[DependencyManager] 创建临时目录: {temp_dir}") await bot.send_text_message(chat_id, f"🔄 正在从GitHub下载插件 {plugin_name}...") # 检查git是否安装,决定使用哪种下载方式 git_installed = self._check_git_installed() logger.info(f"[DependencyManager] Git命令安装状态: {git_installed}") if git_installed: # 使用git克隆仓库 logger.info(f"[DependencyManager] 使用git克隆: {github_url}.git 到 {temp_dir}") process = subprocess.Popen( ["git", "clone", f"{github_url}.git", temp_dir], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) stdout, stderr = process.communicate() logger.info(f"[DependencyManager] Git clone结果:退出码 {process.returncode}") logger.info(f"[DependencyManager] Stdout: {stdout}") logger.info(f"[DependencyManager] Stderr: {stderr}") if process.returncode != 0: logger.error(f"[DependencyManager] Git克隆失败,尝试使用ZIP方式下载") success = await self._download_github_zip(bot, chat_id, user_name, repo_name, temp_dir) if not success: return else: # 使用ZIP方式下载 logger.info(f"[DependencyManager] Git未安装,使用ZIP方式下载") success = await self._download_github_zip(bot, chat_id, user_name, repo_name, temp_dir) if not success: return # 克隆或下载成功,复制到插件目录 logger.info(f"[DependencyManager] 创建插件目录: {plugin_target_dir}") os.makedirs(plugin_target_dir, exist_ok=True) # 复制所有文件 logger.info(f"[DependencyManager] 开始从临时目录复制文件到插件目录") for item in os.listdir(temp_dir): s = os.path.join(temp_dir, item) d = os.path.join(plugin_target_dir, item) logger.info(f"[DependencyManager] 复制: {s} 到 {d}") if os.path.isdir(s): shutil.copytree(s, d, dirs_exist_ok=True) else: shutil.copy2(s, d) logger.info(f"[DependencyManager] 文件复制完成") await bot.send_text_message(chat_id, f"✅ 成功下载插件 {plugin_name}!") # 安装依赖 await self._install_plugin_requirements(bot, chat_id, plugin_target_dir) except Exception as e: logger.exception(f"[DependencyManager] 安装插件时出错") await bot.send_text_message(chat_id, f"❌ 安装插件时出错: {str(e)}") def _check_git_installed(self): """检查git命令是否可用""" try: process = subprocess.Popen( ["git", "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) process.communicate() return process.returncode == 0 except Exception: return False async def _download_github_zip(self, bot, chat_id, user_name, repo_name, target_dir, is_update=False): """使用requests下载GitHub仓库的ZIP文件""" try: # 构建ZIP下载链接 zip_url = f"https://github.com/{user_name}/{repo_name}/archive/refs/heads/main.zip" logger.info(f"[DependencyManager] 开始下载ZIP: {zip_url}") # 发送下载状态 await bot.send_text_message(chat_id, f"📥 正在从GitHub下载ZIP文件: {zip_url}") # 下载ZIP文件 response = requests.get(zip_url, timeout=30) if response.status_code != 200: # 尝试使用master分支 zip_url = f"https://github.com/{user_name}/{repo_name}/archive/refs/heads/master.zip" logger.info(f"[DependencyManager] 尝试下载master分支: {zip_url}") response = requests.get(zip_url, timeout=30) if response.status_code != 200: logger.error(f"[DependencyManager] 下载ZIP失败,状态码: {response.status_code}") await bot.send_text_message(chat_id, f"❌ 下载ZIP文件失败,HTTP状态码: {response.status_code}") return False # 解压ZIP文件 logger.info(f"[DependencyManager] 下载完成,文件大小: {len(response.content)} 字节") logger.info(f"[DependencyManager] 解压ZIP文件到: {target_dir}") z = zipfile.ZipFile(io.BytesIO(response.content)) # 检查ZIP文件内容 zip_contents = z.namelist() logger.info(f"[DependencyManager] ZIP文件内容: {', '.join(zip_contents[:5])}...") if is_update: # 更新时先备份配置文件 config_files = [] if os.path.exists(os.path.join(target_dir, "config.toml")): with open(os.path.join(target_dir, "config.toml"), "rb") as f: config_files.append(("config.toml", f.read())) # 清空目录(保留.git目录) for item in os.listdir(target_dir): if item == ".git": continue item_path = os.path.join(target_dir, item) if os.path.isdir(item_path): shutil.rmtree(item_path) else: os.remove(item_path) # 解压文件 extract_dir = tempfile.mkdtemp() z.extractall(extract_dir) # ZIP文件解压后通常会有一个包含所有文件的顶级目录 extracted_dirs = os.listdir(extract_dir) if len(extracted_dirs) == 1: extract_subdir = os.path.join(extract_dir, extracted_dirs[0]) # 将文件从解压的子目录复制到目标目录 for item in os.listdir(extract_subdir): s = os.path.join(extract_subdir, item) d = os.path.join(target_dir, item) if os.path.isdir(s): shutil.copytree(s, d, dirs_exist_ok=True) else: shutil.copy2(s, d) else: # 直接解压到目标目录 for item in os.listdir(extract_dir): s = os.path.join(extract_dir, item) d = os.path.join(target_dir, item) if os.path.isdir(s): shutil.copytree(s, d, dirs_exist_ok=True) else: shutil.copy2(s, d) # 清理临时目录 shutil.rmtree(extract_dir) # 如果是更新,恢复配置文件 if is_update and config_files: for filename, content in config_files: with open(os.path.join(target_dir, filename), "wb") as f: f.write(content) logger.info(f"[DependencyManager] 已恢复配置文件") await bot.send_text_message(chat_id, f"✅ ZIP文件下载并解压成功") return True except Exception as e: logger.exception(f"[DependencyManager] 下载ZIP文件时出错") await bot.send_text_message(chat_id, f"❌ 下载ZIP文件时出错: {str(e)}") return False async def _install_plugin_requirements(self, bot: WechatAPIClient, chat_id: str, plugin_dir: str): """安装插件的依赖项""" requirements_file = os.path.join(plugin_dir, "requirements.txt") if not os.path.exists(requirements_file): await bot.send_text_message(chat_id, "📌 未找到requirements.txt文件,跳过依赖安装") return try: await bot.send_text_message(chat_id, "📦 正在安装插件依赖...") # 读取requirements.txt内容 with open(requirements_file, "r") as f: requirements = f.read() # 显示依赖列表 await bot.send_text_message(chat_id, f"📋 依赖列表:\n{requirements}") # 安装依赖 process = subprocess.Popen( [sys.executable, "-m", "pip", "install", "-r", requirements_file], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) stdout, stderr = process.communicate() if process.returncode == 0: output = f"✅ 依赖安装成功!\n\n{stdout}" # 如果输出太长,只取前后部分 if len(output) > 1000: output = output[:500] + "\n...\n" + output[-500:] await bot.send_text_message(chat_id, output) # 提示重启机器人 await bot.send_text_message(chat_id, "🔄 插件安装完成!请重启机器人以加载新插件。") else: error = f"❌ 依赖安装失败:\n\n{stderr}" # 如果输出太长,只取前后部分 if len(error) > 1000: error = error[:500] + "\n...\n" + error[-500:] await bot.send_text_message(chat_id, error) except Exception as e: await bot.send_text_message(chat_id, f"❌ 安装依赖时出错: {str(e)}") async def _handle_show(self, bot: WechatAPIClient, chat_id: str, package: str): """处理查询包信息命令""" if not package: await bot.send_text_message(chat_id, "请指定要查询的包,例如: !pip show packagename") return await bot.send_text_message(chat_id, f"🔍 正在查询: {package}...") try: process = subprocess.Popen( [sys.executable, "-m", "pip", "show", package], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) stdout, stderr = process.communicate() if process.returncode == 0: # 查询成功 await bot.send_text_message(chat_id, f"📋 {package} 信息:\n\n{stdout}") else: # 查询失败 await bot.send_text_message(chat_id, f"❌ 查询失败: {package}\n\n{stderr}") except Exception as e: await bot.send_text_message(chat_id, f"❌ 执行查询命令时出错: {str(e)}") async def _handle_list(self, bot: WechatAPIClient, chat_id: str): """处理列出所有包命令""" await bot.send_text_message(chat_id, "📋 正在获取已安装的包列表...") try: process = subprocess.Popen( [sys.executable, "-m", "pip", "list"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) stdout, stderr = process.communicate() if process.returncode == 0: # 获取成功,但可能很长,分段发送 if len(stdout) > 1000: chunks = [stdout[i:i + 1000] for i in range(0, len(stdout), 1000)] await bot.send_text_message(chat_id, f"📦 已安装的包列表 (共{len(chunks)}段):") for i, chunk in enumerate(chunks): await bot.send_text_message(chat_id, f"📦 第{i + 1}段:\n\n{chunk}") else: await bot.send_text_message(chat_id, f"📦 已安装的包列表:\n\n{stdout}") else: # 获取失败 await bot.send_text_message(chat_id, f"❌ 获取列表失败\n\n{stderr}") except Exception as e: await bot.send_text_message(chat_id, f"❌ 执行列表命令时出错: {str(e)}") async def _handle_uninstall(self, bot: WechatAPIClient, chat_id: str, package: str): """处理卸载包命令""" if not package: await bot.send_text_message(chat_id, "请指定要卸载的包,例如: !pip uninstall packagename") return await bot.send_text_message(chat_id, f"🗑️ 正在卸载: {package}...") try: # 使用-y参数自动确认卸载 process = subprocess.Popen( [sys.executable, "-m", "pip", "uninstall", "-y", package], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) stdout, stderr = process.communicate() if process.returncode == 0: # 卸载成功 await bot.send_text_message(chat_id, f"✅ 卸载成功: {package}\n\n{stdout}") else: # 卸载失败 await bot.send_text_message(chat_id, f"❌ 卸载失败: {package}\n\n{stderr}") except Exception as e: await bot.send_text_message(chat_id, f"❌ 执行卸载命令时出错: {str(e)}") async def _send_help(self, bot: WechatAPIClient, chat_id: str): """发送帮助信息""" help_text = f"""📚 依赖包管理插件使用帮助: 1️⃣ 安装包: {self.install_cmd} package_name {self.install_cmd} package_name==1.2.3 (指定版本) 2️⃣ 查询包信息: {self.show_cmd} package_name 3️⃣ 列出所有已安装的包: {self.list_cmd} 4️⃣ 卸载包: {self.uninstall_cmd} package_name 5️⃣ 检查包是否可以导入: !import package_name 6️⃣ 安装GitHub插件: {self.github_install_prefix} https://github.com/用户名/插件名.git ℹ️ 仅允许管理员使用此功能 """ await bot.send_text_message(chat_id, help_text) async def _check_import(self, bot: WechatAPIClient, chat_id: str, package: str): """检查包是否可以成功导入""" if not package: await bot.send_text_message(chat_id, "请指定要检查的包,例如: !import packagename") return await bot.send_text_message(chat_id, f"🔍 正在检查是否可以导入: {package}...") try: # 尝试导入包 importlib.import_module(package) await bot.send_text_message(chat_id, f"✅ {package} 可以成功导入!") except ImportError as e: await bot.send_text_message(chat_id, f"❌ 无法导入 {package}: {str(e)}") except Exception as e: await bot.send_text_message(chat_id, f"❌ 导入 {package} 时发生错误: {str(e)}") async def on_disable(self): """插件禁用时的清理工作""" await super().on_disable() logger.info("[DependencyManager] 插件已禁用") ================================================ FILE: plugins/Dify/__init__.py ================================================ ================================================ FILE: plugins/Dify/config.toml ================================================ [Dify] enable = true api-key = "" # Dify的API Key base-url = "https://api.dify.ai/v1" #Dify API接口base url commands = ["ai", "dify", "聊天", "AI"] command-tip = """-----XYBot----- 💬AI聊天指令: 聊天 请求内容 """ price = 0 # 用一次扣积分,如果0则不扣 admin_ignore = true #admin是否忽略扣除 whitelist_ignore = true #白名单是否忽略扣除 # Http代理设置 # 格式: http://用户名:密码@代理地址:代理端口 # 例如:http://127.0.0.1:7890 http-proxy = "" ================================================ FILE: plugins/Dify/main.py ================================================ import json import re import tomllib import traceback import aiohttp import filetype from loguru import logger from WechatAPI import WechatAPIClient from database.XYBotDB import XYBotDB from utils.decorators import * from utils.plugin_base import PluginBase class Dify(PluginBase): description = "Dify插件" author = "HenryXiaoYang" version = "1.1.0" # Change Log # 1.1.0 2025-02-20 插件优先级,插件阻塞 # 1.2.0 2025-02-22 有插件阻塞了,other-plugin-cmd可删了 def __init__(self): super().__init__() with open("main_config.toml", "rb") as f: config = tomllib.load(f) self.admins = config["XYBot"]["admins"] with open("plugins/Dify/config.toml", "rb") as f: config = tomllib.load(f) plugin_config = config["Dify"] self.enable = plugin_config["enable"] self.api_key = plugin_config["api-key"] self.base_url = plugin_config["base-url"] self.commands = plugin_config["commands"] self.command_tip = plugin_config["command-tip"] self.price = plugin_config["price"] self.admin_ignore = plugin_config["admin_ignore"] self.whitelist_ignore = plugin_config["whitelist_ignore"] self.http_proxy = plugin_config["http-proxy"] self.db = XYBotDB() @on_text_message(priority=20) async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return command = str(message["Content"]).strip().split(" ") if (not command or command[0] not in self.commands) and message["IsGroup"]: # 不是指令,且是群聊 return elif len(command) == 1 and command[0] in self.commands: # 只是指令,但没请求内容 await bot.send_at_message(message["FromWxid"], "\n" + self.command_tip, [message["SenderWxid"]]) return if not self.api_key: await bot.send_at_message(message["FromWxid"], "\n你还没配置Dify API密钥!", [message["SenderWxid"]]) return False if await self._check_point(bot, message): await self.dify(bot, message, message["Content"]) return False @on_at_message(priority=20) async def handle_at(self, bot: WechatAPIClient, message: dict): if not self.enable: return if not self.api_key: await bot.send_at_message(message["FromWxid"], "\n你还没配置Dify API密钥!", [message["SenderWxid"]]) return False if await self._check_point(bot, message): await self.dify(bot, message, message["Content"]) return False @on_voice_message(priority=20) async def handle_voice(self, bot: WechatAPIClient, message: dict): if not self.enable: return if message["IsGroup"]: return if not self.api_key: await bot.send_at_message(message["FromWxid"], "\n你还没配置Dify API密钥!", [message["SenderWxid"]]) return False if await self._check_point(bot, message): upload_file_id = await self.upload_file(message["FromWxid"], message["Content"]) files = [ { "type": "audio", "transfer_method": "local_file", "upload_file_id": upload_file_id } ] await self.dify(bot, message, " \n", files) return False @on_image_message(priority=20) async def handle_image(self, bot: WechatAPIClient, message: dict): if not self.enable: return if message["IsGroup"]: return if not self.api_key: await bot.send_at_message(message["FromWxid"], "\n你还没配置Dify API密钥!", [message["SenderWxid"]]) return False if await self._check_point(bot, message): upload_file_id = await self.upload_file(message["FromWxid"], bot.base64_to_byte(message["Content"])) files = [ { "type": "image", "transfer_method": "local_file", "upload_file_id": upload_file_id } ] await self.dify(bot, message, " \n", files) return False @on_video_message(priority=20) async def handle_video(self, bot: WechatAPIClient, message: dict): if not self.enable: return if message["IsGroup"]: return if not self.api_key: await bot.send_at_message(message["FromWxid"], "\n你还没配置Dify API密钥!", [message["SenderWxid"]]) return False if await self._check_point(bot, message): upload_file_id = await self.upload_file(message["FromWxid"], bot.base64_to_byte(message["Video"])) files = [ { "type": "video", "transfer_method": "local_file", "upload_file_id": upload_file_id } ] await self.dify(bot, message, " \n", files) return False @on_file_message(priority=20) async def handle_file(self, bot: WechatAPIClient, message: dict): if not self.enable: return if message["IsGroup"]: return if not self.api_key: await bot.send_at_message(message["FromWxid"], "\n你还没配置Dify API密钥!", [message["SenderWxid"]]) return False if await self._check_point(bot, message): upload_file_id = await self.upload_file(message["FromWxid"], message["Content"]) files = [ { "type": "document", "transfer_method": "local_file", "upload_file_id": upload_file_id } ] await self.dify(bot, message, " \n", files) return False async def dify(self, bot: WechatAPIClient, message: dict, query: str, files=None): if files is None: files = [] conversation_id = self.db.get_llm_thread_id(message["FromWxid"], namespace="dify") headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} payload = json.dumps({ "inputs": {}, "query": query, "response_mode": "streaming", "conversation_id": conversation_id, "user": message["FromWxid"], "files": files, "auto_generate_name": False, }) url = f"{self.base_url}/chat-messages" ai_resp = "" async with aiohttp.ClientSession(proxy=self.http_proxy) as session: async with session.post(url=url, headers=headers, data=payload) as resp: if resp.status == 200: # 读取响应 async for line in resp.content: # 流式传输 line = line.decode("utf-8").strip() if not line or line == "event: ping": # 空行或ping continue elif line.startswith("data: "): # 脑瘫吧,为什么前面要加 "data: " ??? line = line[6:] try: resp_json = json.loads(line) except json.decoder.JSONDecodeError: logger.error(f"Dify返回的JSON解析错误,请检查格式: {line}") event = resp_json.get("event", "") if event == "message": # LLM 返回文本块事件 ai_resp += resp_json.get("answer", "") elif event == "message_replace": # 消息内容替换事件 ai_resp = resp_json("answer", "") elif event == "message_file": # 文件事件 目前dify只输出图片 await self.dify_handle_image(bot, message, resp_json.get("url", "")) elif event == "tts_message": # TTS 音频流结束事件 await self.dify_handle_audio(bot, message, resp_json.get("audio", "")) elif event == "error": # 流式输出过程中出现的异常 await self.dify_handle_error(bot, message, resp_json.get("task_id", ""), resp_json.get("message_id", ""), resp_json.get("status", ""), resp_json.get("code", ""), resp_json.get("message", "")) new_con_id = resp_json.get("conversation_id", "") if new_con_id and new_con_id != conversation_id: self.db.save_llm_thread_id(message["FromWxid"], new_con_id, "dify") elif resp.status == 404: self.db.save_llm_thread_id(message["FromWxid"], "", "dify") return await self.dify(bot, message, query) elif resp.status == 400: return await self.handle_400(bot, message, resp) elif resp.status == 500: return await self.handle_500(bot, message) else: return await self.handle_other_status(bot, message, resp) if ai_resp: await self.dify_handle_text(bot, message, ai_resp) async def upload_file(self, user: str, file: bytes): headers = {"Authorization": f"Bearer {self.api_key}"} # user multipart/form-data kind = filetype.guess(file) formdata = aiohttp.FormData() formdata.add_field("user", user) formdata.add_field("file", file, filename=kind.extension, content_type=kind.mime) url = f"{self.base_url}/files/upload" async with aiohttp.ClientSession(proxy=self.http_proxy) as session: async with session.post(url, headers=headers, data=formdata) as resp: resp_json = await resp.json() return resp_json.get("id", "") async def dify_handle_text(self, bot: WechatAPIClient, message: dict, text: str): pattern = r"\]\((https?:\/\/[^\s\)]+)\)" links = re.findall(pattern, text) for url in links: file = await self.download_file(url) extension = filetype.guess_extension(file) if extension in ('wav', 'mp3'): await bot.send_voice_message(message["FromWxid"], voice=file, format=filetype.guess_extension(file)) elif extension in ('jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg'): await bot.send_image_message(message["FromWxid"], file) elif extension in ('mp4', 'avi', 'mov', 'mkv', 'flv'): await bot.send_video_message(message["FromWxid"], video=file, image="None") pattern = r'\[[^\]]+\]\(https?:\/\/[^\s\)]+\)' text = re.sub(pattern, '', text) if text: await bot.send_at_message(message["FromWxid"], "\n" + text, [message["SenderWxid"]]) async def download_file(self, url: str) -> bytes: async with aiohttp.ClientSession(proxy=self.http_proxy) as session: async with session.get(url) as resp: return await resp.read() async def dify_handle_image(self, bot: WechatAPIClient, message: dict, image: Union[str, bytes]): if isinstance(image, str) and image.startswith("http"): async with aiohttp.ClientSession(proxy=self.http_proxy) as session: async with session.get(image) as resp: image = bot.byte_to_base64(await resp.read()) elif isinstance(image, bytes): image = bot.byte_to_base64(image) await bot.send_image_message(message["FromWxid"], image) @staticmethod async def dify_handle_audio(bot: WechatAPIClient, message: dict, audio: str): await bot.send_voice_message(message["FromWxid"], audio) @staticmethod async def dify_handle_error(bot: WechatAPIClient, message: dict, task_id: str, message_id: str, status: str, code: int, err_message: str): output = ("-----XYBot-----\n" "🙅对不起,Dify出现错误!\n" f"任务 ID:{task_id}\n" f"消息唯一 ID:{message_id}\n" f"HTTP 状态码:{status}\n" f"错误码:{code}\n" f"错误信息:{err_message}") await bot.send_at_message(message["FromWxid"], "\n" + output, [message["SenderWxid"]]) @staticmethod async def handle_400(bot: WechatAPIClient, message: dict, resp: aiohttp.ClientResponse): output = ("-----XYBot-----\n" "🙅对不起,出现错误!\n" f"错误信息:{(await resp.content.read()).decode('utf-8')}") await bot.send_at_message(message["FromWxid"], "\n" + output, [message["SenderWxid"]]) @staticmethod async def handle_500(bot: WechatAPIClient, message: dict): output = "-----XYBot-----\n🙅对不起,Dify服务内部异常,请稍后再试。" await bot.send_at_message(message["FromWxid"], "\n" + output, [message["SenderWxid"]]) @staticmethod async def handle_other_status(bot: WechatAPIClient, message: dict, resp: aiohttp.ClientResponse): ai_resp = ("-----XYBot-----\n" f"🙅对不起,出现错误!\n" f"状态码:{resp.status}\n" f"错误信息:{(await resp.content.read()).decode('utf-8')}") await bot.send_at_message(message["FromWxid"], "\n" + ai_resp, [message["SenderWxid"]]) @staticmethod async def hendle_exceptions(bot: WechatAPIClient, message: dict): output = ("-----XYBot-----\n" "🙅对不起,出现错误!\n" f"错误信息:\n" f"{traceback.format_exc()}") await bot.send_at_message(message["FromWxid"], "\n" + output, [message["SenderWxid"]]) async def _check_point(self, bot: WechatAPIClient, message: dict) -> bool: wxid = message["SenderWxid"] if wxid in self.admins and self.admin_ignore: return True elif self.db.get_whitelist(wxid) and self.whitelist_ignore: return True else: if self.db.get_points(wxid) < self.price: await bot.send_at_message(message["FromWxid"], f"\n-----XYBot-----\n" f"😭你的积分不够啦!需要 {self.price} 积分", [wxid]) return False self.db.add_points(wxid, -self.price) return True ================================================ FILE: plugins/DouyinParser/__init__.py ================================================ ================================================ FILE: plugins/DouyinParser/config.toml ================================================ [basic] enable = true # Http代理设置(用于获取真实链接发送卡片,如果家里有ipv6,可以设置为空) # 格式: http://用户名:密码@代理地址:代理端口 # 例如:http://127.0.0.1:7890 http_proxy = "" ================================================ FILE: plugins/DouyinParser/main.py ================================================ import re import tomllib import os from typing import Dict, Any import traceback import asyncio import aiohttp from loguru import logger from WechatAPI import WechatAPIClient from utils.decorators import on_text_message from utils.plugin_base import PluginBase class DouyinParserError(Exception): """抖音解析器自定义异常基类""" pass class DouyinParser(PluginBase): description = "抖音无水印解析插件" author = "姜不吃先生" # 群友太给力了! version = "1.0.2" def __init__(self): super().__init__() self.url_pattern = re.compile(r'https?://v\.douyin\.com/\w+/?') # 读取代理配置 config_path = os.path.join(os.path.dirname(__file__), "config.toml") try: with open(config_path, "rb") as f: config = tomllib.load(f) # 基础配置 basic_config = config.get("basic", {}) self.enable = basic_config.get("enable", True) self.http_proxy = basic_config.get("http_proxy", None) except Exception as e: logger.error(f"加载抖音解析器配置文件失败: {str(e)}") self.enable = True self.http_proxy = None logger.debug("[抖音] 插件初始化完成,代理设置: {}", self.http_proxy) def _clean_response_data(self, data: Dict[str, Any]) -> Dict[str, Any]: """清理响应数据""" if not data: return data # 使用固定的抖音图标作为封面 data[ 'cover'] = "https://is1-ssl.mzstatic.com/image/thumb/Purple221/v4/7c/49/e1/7c49e1af-ce92-d1c4-9a93-0a316e47ba94/AppIcon_TikTok-0-0-1x_U007epad-0-1-0-0-85-220.png/512x512bb.jpg" return data def _clean_url(self, url: str) -> str: """清理URL中的特殊字符""" cleaned_url = url.strip().replace(';', '').replace('\n', '').replace('\r', '') logger.debug("[抖音] 清理后的URL: {}", cleaned_url) # 添加日志 return cleaned_url async def _get_real_video_url(self, video_url: str) -> str: """获取真实视频链接""" max_retries = 3 # 最大重试次数 retry_delay = 2 # 重试延迟秒数 for retry in range(max_retries): try: logger.info("[抖音] 开始获取真实视频链接: {} (第{}次尝试)", video_url, retry + 1) # 修正代理格式 proxy = f"http://{self.http_proxy}" if self.http_proxy and not self.http_proxy.startswith(('http://', 'https://')) else self.http_proxy logger.debug("[抖音] 使用代理: {}", proxy) headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Range': 'bytes=0-' } async with aiohttp.ClientSession() as session: async with session.get(video_url, proxy=proxy, headers=headers, allow_redirects=True, timeout=60) as response: # 延长超时时间到60秒 if response.status == 200 or response.status == 206: # 获取所有重定向历史 history = [str(resp.url) for resp in response.history] real_url = str(response.url) # 记录重定向链接历史,用于调试 if history: logger.debug("[抖音] 重定向历史: {}", history) # 检查是否获取到了真实的视频URL if real_url != video_url and ('v3-' in real_url.lower() or 'douyinvod.com' in real_url.lower()): logger.info("[抖音] 成功获取真实链接: {}", real_url) return real_url else: logger.warning("[抖音] 未能获取到真实视频链接,准备重试") if retry < max_retries - 1: # 如果不是最后一次尝试,则等待后重试 await asyncio.sleep(retry_delay) continue return video_url else: logger.error("[抖音] 获取视频真实链接失败, 状态码: {}", response.status) logger.debug("[抖音] 响应头: {}", response.headers) if retry < max_retries - 1: await asyncio.sleep(retry_delay) continue return video_url except Exception as e: logger.error("[抖音] 获取真实链接失败: {} (第{}次尝试)", str(e), retry + 1) if retry < max_retries - 1: await asyncio.sleep(retry_delay) continue return video_url logger.error("[抖音] 获取真实链接失败,已达到最大重试次数") return video_url async def _parse_douyin(self, url: str) -> Dict[str, Any]: """调用抖音解析API""" try: api_url = "https://apih.kfcgw50.me/api/douyin" clean_url = self._clean_url(url) params = { 'url': clean_url, 'type': 'json' } logger.debug("[抖音] 请求API: {}, 参数: {}", api_url, repr(params)) # 添加日志 async with aiohttp.ClientSession() as session: # 使用代理 proxy = f"http://{self.http_proxy}" if self.http_proxy and not self.http_proxy.startswith(('http://', 'https://')) else self.http_proxy async with session.get(api_url, params=params, timeout=30, proxy=proxy) as response: # 使用代理 if response.status != 200: raise DouyinParserError(f"API请求失败,状态码: {response.status}") data = await response.json() logger.debug("[抖音] API响应数据: {}", data) # 添加日志 if data.get("code") == 200: result = data.get("data", {}) if not result: raise DouyinParserError("API返回数据为空") # 获取真实视频链接 if result.get('video'): result['video'] = await self._get_real_video_url(result['video']) result = self._clean_response_data(result) logger.debug("[抖音] 清理后的数据: {}", result) return result else: raise DouyinParserError(data.get("message", "未知错误")) except (aiohttp.ClientTimeout, aiohttp.ClientError) as e: logger.error("[抖音] 解析失败: {}", str(e)) raise DouyinParserError(str(e)) except Exception as e: logger.error("[抖音] 解析过程发生未知错误: {}\n{}", str(e), traceback.format_exc()) raise DouyinParserError(f"未知错误: {str(e)}") async def _send_test_card(self, bot: WechatAPIClient, chat_id: str, sender: str): """发送测试卡片消息""" try: # 测试数据 test_data = { 'video': 'https://v11-cold.douyinvod.com/c183ceff049f008265680819dbd8ac0a/67b206c0/video/tos/cn/tos-cn-ve-15/ok8JumeiqAI3pJ2nAiQE9rBiTfm1KtADABlBgV/?a=1128&ch=0&cr=0&dr=0&cd=0%7C0%7C0%7C0&cv=1&br=532&bt=532&cs=0&ds=3&ft=H4NIyvvBQx9Uf8ym8Z.6TQjSYE7OYMDtGkd~P4Aq8_45a&mime_type=video_mp4&qs=0&rc=ZzU5NTRnNDw1aGc5aDloZkBpanE4M3Y5cjNkeDMzNGkzM0AuLy1fLWFhXjQxNjFgYzRiYSNmXzZlMmRjcmdgLS1kLTBzcw%3D%3D&btag=80010e000ad000&cquery=100y&dy_q=1739716635&feature_id=aa7df520beeae8e397df15f38df0454c&l=20250216223715047FF68C05B9F67E1F19', 'title': '测试视频标题', 'name': '测试作者', 'cover': 'https://is1-ssl.mzstatic.com/image/thumb/Purple221/v4/7c/49/e1/7c49e1af-ce92-d1c4-9a93-0a316e47ba94/AppIcon_TikTok-0-0-1x_U007epad-0-1-0-0-85-220.png/512x512bb.jpg' } logger.info("开始发送测试卡片") logger.debug(f"测试数据: {test_data}") # 发送测试卡片 await bot.send_link_message( wxid=chat_id, url=test_data['video'], title=f"{test_data['title'][:30]} - {test_data['name'][:10]}", description="这是一个测试卡片消息", thumb_url=test_data['cover'] ) logger.info("测试卡片发送成功") # 发送详细信息 debug_msg = ( "🔍 测试卡片详情:\n" f"视频链接: {test_data['video']}\n" f"封面链接: {test_data['cover']}\n" f"标题: {test_data['title']} - {test_data['name']}" ) await bot.send_text_message( wxid=chat_id, content=debug_msg, at=[sender] ) except Exception as e: error_msg = f"测试卡片发送失败: {str(e)}" logger.error(error_msg) await bot.send_text_message( wxid=chat_id, content=error_msg, at=[sender] ) @on_text_message(priority=80) async def handle_douyin_links(self, bot: WechatAPIClient, message: dict): if not self.enable: return True content = message['Content'] sender = message['SenderWxid'] chat_id = message['FromWxid'] # 添加测试命令识别 if content.strip() == "测试卡片": await self._send_test_card(bot, chat_id, sender) return try: # 提取抖音链接并清理 match = self.url_pattern.search(content) if not match: return original_url = self._clean_url(match.group(0)) logger.info(f"发现抖音链接: {original_url}") # 添加解析提示 msg_args = { 'wxid': chat_id, 'content': "检测到抖音分享链接,正在解析无水印视频...\n" if message['IsGroup'] else "检测到抖音分享链接,正在解析无水印视频..." } if message['IsGroup']: msg_args['at'] = [sender] await bot.send_text_message(**msg_args) # 解析视频信息 video_info = await self._parse_douyin(original_url) if not video_info: raise DouyinParserError("无法获取视频信息") # 获取视频信息 video_url = video_info.get('video', '') title = video_info.get('title', '无标题') author = video_info.get('name', '未知作者') cover = video_info.get('cover', '') if not video_url: raise DouyinParserError("无法获取视频地址") # 发送文字版消息 text_msg = ( f"🎬 解析成功,微信内可直接观看(需ipv6),浏览器打开可下载保存。\n" f"链接含有有效期,请尽快保存。\n" ) if message['IsGroup']: text_msg = text_msg + "\n" await bot.send_text_message(wxid=chat_id, content=text_msg, at=[sender]) else: await bot.send_text_message(wxid=chat_id, content=text_msg) # 发送卡片版消息 await bot.send_link_message( wxid=chat_id, url=video_url, title=f"{title[:30]} - {author[:10]}" if author else title[:40], description="点击观看无水印视频", thumb_url=cover ) logger.info(f"已发送解析结果: 标题[{title}] 作者[{author}]") except DouyinParserError as e: error_msg = str(e) if str(e) else "解析失败" logger.error(f"抖音解析失败: {error_msg}") if message['IsGroup']: await bot.send_text_message(wxid=chat_id, content=f"视频解析失败: {error_msg}\n", at=[sender]) else: await bot.send_text_message(wxid=chat_id, content=f"视频解析失败: {error_msg}") except Exception as e: error_msg = str(e) if str(e) else "未知错误" logger.error(f"抖音解析发生未知错误: {error_msg}") if message['IsGroup']: await bot.send_text_message(wxid=chat_id, content=f"视频解析失败: {error_msg}\n", at=[sender]) else: await bot.send_text_message(wxid=chat_id, content=f"视频解析失败: {error_msg}") async def async_init(self): """异步初始化函数""" # 可以在这里进行一些异步的初始化操作 # 比如测试API可用性等 pass ================================================ FILE: plugins/ExamplePlugin/__init__.py ================================================ ================================================ FILE: plugins/ExamplePlugin/config.toml ================================================ [basic] # 是否启用插件 enable = true ================================================ FILE: plugins/ExamplePlugin/main.py ================================================ from loguru import logger import tomllib # 确保导入tomllib以读取配置文件 import os # 确保导入os模块 from WechatAPI import WechatAPIClient from utils.decorators import * from utils.plugin_base import PluginBase class ExamplePlugin(PluginBase): description = "示例插件" author = "HenryXiaoYang" version = "1.0.0" # 同步初始化 def __init__(self): super().__init__() # 获取配置文件路径 config_path = os.path.join(os.path.dirname(__file__), "config.toml") try: with open(config_path, "rb") as f: config = tomllib.load(f) # 读取基本配置 basic_config = config.get("basic", {}) self.enable = basic_config.get("enable", False) # 读取插件开关 except Exception as e: logger.error(f"加载ExamplePlugin配置文件失败: {str(e)}") self.enable = False # 如果加载失败,禁用插件 # 异步初始化 async def async_init(self): return @on_text_message(priority=99) async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return # 如果插件未启用,直接返回 logger.info("收到了文本消息。") @on_at_message(priority=50) async def handle_at(self, bot: WechatAPIClient, message: dict): if not self.enable: return logger.info("收到了被@消息,中等优先级") @on_voice_message() async def handle_voice(self, bot: WechatAPIClient, message: dict): if not self.enable: return logger.info("收到了语音消息,最低优先级") @on_image_message async def handle_image(self, bot: WechatAPIClient, message: dict): if not self.enable: return logger.info("收到了图片消息") @on_video_message async def handle_video(self, bot: WechatAPIClient, message: dict): if not self.enable: return logger.info("收到了视频消息") @on_file_message async def handle_file(self, bot: WechatAPIClient, message: dict): if not self.enable: return logger.info("收到了文件消息") @on_quote_message async def handle_quote(self, bot: WechatAPIClient, message: dict): if not self.enable: return logger.info("收到了引用消息") @on_pat_message async def handle_pat(self, bot: WechatAPIClient, message: dict): if not self.enable: return logger.info("收到了拍一拍消息") @on_emoji_message async def handle_emoji(self, bot: WechatAPIClient, message: dict): if not self.enable: return logger.info("收到了表情消息") @schedule('interval', seconds=5) async def periodic_task(self, bot: WechatAPIClient): if not self.enable: return logger.info("我每5秒执行一次") @schedule('cron', hour=8, minute=30, second=30) async def daily_task(self, bot: WechatAPIClient): if not self.enable: return logger.info("我每天早上8点30分30秒执行") @schedule('date', run_date='2025-01-29 00:00:00') async def new_year_task(self, bot: WechatAPIClient): if not self.enable: return logger.info("我在2025年1月29日执行") ================================================ FILE: plugins/GetContact/__init__.py ================================================ ================================================ FILE: plugins/GetContact/config.toml ================================================ [GetContact] enable = true command = ["获取联系人", "联系人", "通讯录", "获取通讯录"] ================================================ FILE: plugins/GetContact/main.py ================================================ import asyncio import tomllib from datetime import datetime import aiohttp from loguru import logger from tabulate import tabulate from WechatAPI import WechatAPIClient from utils.decorators import * from utils.plugin_base import PluginBase class GetContact(PluginBase): description = "获取通讯录" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/GetContact/config.toml", "rb") as f: plugin_config = tomllib.load(f) with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) config = plugin_config["GetContact"] main_config = main_config["XYBot"] self.enable = config["enable"] self.command = config["command"] self.admins = main_config["admins"] @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if not len(command) or command[0] not in self.command: return sender_wxid = message["SenderWxid"] if sender_wxid not in self.admins: await bot.send_text_message(message["FromWxid"], "-----XYBot-----\n❌你配用这个指令吗?😡") return a, b, c = await bot.send_text_message(message["FromWxid"], "-----XYBot-----\n正在获取通讯录信息,请稍等...") start_time = datetime.now() logger.info("开始获取通讯录信息时间:{}", start_time) id_list = [] wx_seq, chatroom_seq = 0, 0 while True: contact_list = await bot.get_contract_list(wx_seq, chatroom_seq) id_list.extend(contact_list["ContactUsernameList"]) wx_seq = contact_list["CurrentWxcontactSeq"] chatroom_seq = contact_list["CurrentChatRoomContactSeq"] if contact_list["CountinueFlag"] != 1: break get_list_time = datetime.now() logger.info("获取通讯录信息列表耗时:{}", get_list_time - start_time) # 使用协程池处理联系人信息获取 info_list = [] async def fetch_contacts(id_chunk): contact_info = await bot.get_contact(id_chunk) return contact_info chunks = [id_list[i:i + 20] for i in range(0, len(id_list), 20)] sem = asyncio.Semaphore(20) async def worker(chunk): async with sem: return await fetch_contacts(chunk[:-1]) # 去掉最后一个ID,保持与原代码一致 tasks = [worker(chunk) for chunk in chunks] results = await asyncio.gather(*tasks) # 合并结果 for result in results: info_list.extend(result) done_time = datetime.now() logger.info("获取通讯录详细信息耗时:{}", done_time - get_list_time) logger.info("获取通讯录信息总耗时:{}", done_time - start_time) clean_info = [] for info in info_list: if info.get("UserName", {}).get("string", ""): clean_info.append({ "Wxid": info.get("UserName", {}).get("string", ""), "Nickname": info.get("NickName", {}).get("string", ""), "Remark": info.get("Remark", {}).get("string"), "Alias": info.get("Alias", "")}) table = str(tabulate(clean_info, headers="keys", stralign="left")) payload = {"content": table} conn_ssl = aiohttp.TCPConnector(ssl=False) async with aiohttp.request("POST", url="https://easychuan.cn/texts", connector=conn_ssl, json=payload) as req: resp = await req.json() await conn_ssl.close() await bot.send_link_message(message["FromWxid"], url=f"https://easychuan.cn/r/{resp['fetch_code']}?t=t", title="XYBot登录账号通讯录", description=f"过期时间:{resp['date_expire']}、耗时:{done_time - start_time}、点击查看详细通讯录信息", ) await bot.revoke_message(message["FromWxid"], a, b, c) ================================================ FILE: plugins/GetWeather/__init__.py ================================================ ================================================ FILE: plugins/GetWeather/config.toml ================================================ [GetWeather] enable = true command-format = """⚙️获取天气: 天气 城市名 天气城市名 城市名天气 城市名 天气""" api-key = "" # 申请链接: https://dev.qweather.com/ 项目订阅选免费订阅即可,把获得到的Key (不是Public ID 而是Private KEY) 填到上面引号中 ================================================ FILE: plugins/GetWeather/main.py ================================================ import tomllib import aiohttp import jieba from WechatAPI import WechatAPIClient from utils.decorators import * from utils.plugin_base import PluginBase class GetWeather(PluginBase): description = "获取天气" author = "HenryXiaoYang" version = "1.0.1" # Change Log # 1.0.1 2025-02-20 修改天气插件触发条件 def __init__(self): super().__init__() with open("plugins/GetWeather/config.toml", "rb") as f: plugin_config = tomllib.load(f) config = plugin_config["GetWeather"] self.enable = config["enable"] self.command_format = config["command-format"] self.api_key = config["api-key"] @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return if "天气" not in message["Content"]: return content = str(message["Content"]).replace(" ", "") command = list(jieba.cut(content)) if len(command) == 1: await bot.send_at_message(message["FromWxid"], "\n" + self.command_format, [message["SenderWxid"]]) return elif len(command) > 3: return # 配置密钥检查 if not self.api_key: await bot.send_at_message(message["FromWxid"], "\n你还没配置天气API密钥!", [message["SenderWxid"]]) return command.remove("天气") request_loc = "".join(command) geo_api_url = f'https://geoapi.qweather.com/v2/city/lookup?key={self.api_key}&number=1&location={request_loc}' conn_ssl = aiohttp.TCPConnector(ssl=False) async with aiohttp.request('GET', url=geo_api_url, connector=conn_ssl) as response: geoapi_json = await response.json() await conn_ssl.close() if geoapi_json['code'] == '404': await bot.send_at_message(message["FromWxid"], "\n⚠️查无此地!", [message["SenderWxid"]]) return elif geoapi_json['code'] != '200': await bot.send_at_message(message["FromWxid"], f"\n⚠️请求失败\n{geoapi_json}", [message["SenderWxid"]]) return country = geoapi_json["location"][0]["country"] adm1 = geoapi_json["location"][0]["adm1"] adm2 = geoapi_json["location"][0]["adm2"] city_id = geoapi_json["location"][0]["id"] # 请求现在天气api conn_ssl = aiohttp.TCPConnector(verify_ssl=False) now_weather_api_url = f'https://devapi.qweather.com/v7/weather/now?key={self.api_key}&location={city_id}' async with aiohttp.request('GET', url=now_weather_api_url, connector=conn_ssl) as response: now_weather_api_json = await response.json() await conn_ssl.close() # 请求预报天气api conn_ssl = aiohttp.TCPConnector(verify_ssl=False) weather_forecast_api_url = f'https://devapi.qweather.com/v7/weather/7d?key={self.api_key}&location={city_id}' async with aiohttp.request('GET', url=weather_forecast_api_url, connector=conn_ssl) as response: weather_forecast_api_json = await response.json() await conn_ssl.close() out_message = self.compose_weather_message(country, adm1, adm2, now_weather_api_json, weather_forecast_api_json) await bot.send_at_message(message["FromWxid"], "\n" + out_message, [message["SenderWxid"]]) @staticmethod def compose_weather_message(country, adm1, adm2, now_weather_api_json, weather_forecast_api_json): update_time = now_weather_api_json['updateTime'] now_temperature = now_weather_api_json['now']['temp'] now_feelslike = now_weather_api_json['now']['feelsLike'] now_weather = now_weather_api_json['now']['text'] now_wind_direction = now_weather_api_json['now']['windDir'] now_wind_scale = now_weather_api_json['now']['windScale'] now_humidity = now_weather_api_json['now']['humidity'] now_precip = now_weather_api_json['now']['precip'] now_visibility = now_weather_api_json['now']['vis'] now_uvindex = weather_forecast_api_json['daily'][0]['uvIndex'] message = ( f"----- XYBot -----\n" f"{country}{adm1}{adm2} 实时天气☁️\n" f"⏰更新时间:{update_time}\n\n" f"🌡️当前温度:{now_temperature}℃\n" f"🌡️体感温度:{now_feelslike}℃\n" f"☁️天气:{now_weather}\n" f"☀️紫外线指数:{now_uvindex}\n" f"🌬️风向:{now_wind_direction}\n" f"🌬️风力:{now_wind_scale}级\n" f"💦湿度:{now_humidity}%\n" f"🌧️降水量:{now_precip}mm/h\n" f"👀能见度:{now_visibility}km\n\n" f"☁️未来3天 {adm2} 天气:\n" ) for day in weather_forecast_api_json['daily'][1:4]: date = '.'.join([i.lstrip('0') for i in day['fxDate'].split('-')[1:]]) weather = day['textDay'] max_temp = day['tempMax'] min_temp = day['tempMin'] uv_index = day['uvIndex'] message += f'{date} {weather} 最高🌡️{max_temp}℃ 最低🌡️{min_temp}℃ ☀️紫外线:{uv_index}\n' return message ================================================ FILE: plugins/Gomoku/__init__.py ================================================ ================================================ FILE: plugins/Gomoku/config.toml ================================================ [Gomoku] enable = true command-format = """⚙️五子棋游戏指令: ♟️创建游戏: 五子棋邀请 @用户 👌接受游戏: 接受 游戏ID ⬇️下棋: 五子棋下 坐标 例如: 下棋 C5""" timeout = 60 command = ["五子棋"] create-game-commands = ["五子棋创建", "五子棋邀请", "邀请五子棋"] accept-game-commands = ["接受", "加入"] play-game-commands = ["下棋"] ================================================ FILE: plugins/Gomoku/main.py ================================================ import asyncio import base64 import tomllib from random import sample from PIL import Image, ImageDraw from WechatAPI import WechatAPIClient from database.XYBotDB import XYBotDB from utils.decorators import * from utils.plugin_base import PluginBase class Gomoku(PluginBase): description = "五子棋游戏" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/Gomoku/config.toml", "rb") as f: plugin_config = tomllib.load(f) config = plugin_config["Gomoku"] self.enable = config["enable"] self.command_format = config["command-format"] self.timeout = config["timeout"] self.command = config["command"] self.create_game_commands = config["create-game-commands"] self.accept_game_commands = config["accept-game-commands"] self.play_game_commands = config["play-game-commands"] self.db = XYBotDB() # 游戏状态存储 self.gomoku_games = {} # 存储所有进行中的游戏 self.gomoku_players = {} # 存储玩家与游戏的对应关系 @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip(" ") command = content.split(" ") if command[0] in self.create_game_commands: await self.create_game(bot, message) elif command[0] in self.accept_game_commands: await self.accept_game(bot, message) elif command[0] in self.play_game_commands: await self.play_game(bot, message) elif command[0] in self.command: # 当用户只输入"五子棋"时显示帮助 await bot.send_text_message(message["FromWxid"], f"-----XYBot-----\n{self.command_format}") async def create_game(self, bot: WechatAPIClient, message: dict): """创建五子棋游戏""" error = '' room_id = message["FromWxid"] sender = message["SenderWxid"] if not message["IsGroup"]: error = '-----XYBot-----\n❌请在群聊中游玩五子棋' elif sender in self.gomoku_players: error = '-----XYBot-----\n❌您已经在一场游戏中了!' if error: await bot.send_text_message(message["FromWxid"], error) return # 获取被邀请者 if len(message["Ats"]) != 1: await bot.send_text_message(room_id, '-----XYBot-----\n❌请@要邀请的玩家!') return invitee_wxid = message["Ats"][0] if invitee_wxid in self.gomoku_players: await bot.send_text_message(room_id, '-----XYBot-----\n❌对方已经在一场游戏中!') return # 创建游戏 game_id = self._generate_game_id() self.gomoku_players[sender] = game_id self.gomoku_players[invitee_wxid] = game_id inviter_nick = await bot.get_nickname(sender) # 发送邀请消息 out_message = (f"\n-----XYBot-----\n" f"🎉您收到了来自 {inviter_nick} 的五子棋比赛邀请!\n" f"\n" f"⚙️请在{self.timeout}秒内发送:\n" f"接受 {game_id}") await bot.send_at_message(room_id, out_message, [invitee_wxid]) # 创建游戏数据 self.gomoku_games[game_id] = { 'black': sender, 'white': invitee_wxid, 'board': None, 'turn': None, 'status': 'inviting', 'chatroom': room_id, 'timeout_task': asyncio.create_task( self._handle_invite_timeout(bot, game_id, sender, invitee_wxid, room_id) ) } async def accept_game(self, bot: WechatAPIClient, message: dict): """接受五子棋游戏""" error = '' room_id = message["FromWxid"] sender = message["SenderWxid"] if not message["IsGroup"]: error = '-----XYBot-----\n❌请在群聊中游玩五子棋' command = message["Content"].strip().split() if len(command) != 2: error = f'-----XYBot-----\n❌指令格式错误\n\n{self.command_format}' if error: await bot.send_text_message(message["FromWxid"], error) return game_id = command[1] if game_id not in self.gomoku_games: await bot.send_text_message(room_id, '-----XYBot-----\n❌该游戏不存在!') return game = self.gomoku_games[game_id] if game['white'] != sender: await bot.send_text_message(room_id, '-----XYBot-----\n❌您没有被邀请参加该游戏!') return if game['status'] != 'inviting': await bot.send_text_message(room_id, '-----XYBot-----\n❌该游戏已经开始或结束!') return if room_id != game['chatroom']: await bot.send_text_message(room_id, '-----XYBot-----\n❌请在原群聊中接受邀请!') return # 取消超时任务 game['timeout_task'].cancel() # 初始化游戏 game['status'] = 'playing' game['board'] = [[0 for _ in range(17)] for _ in range(17)] game['turn'] = game['black'] # 发送游戏开始信息 black_nick = await bot.get_nickname(game['black']) white_nick = await bot.get_nickname(game['white']) start_msg = ( f"-----XYBot-----\n" f"🎉五子棋游戏 {game_id} 开始!\n" f"\n" f"⚫️黑方:{black_nick}\n" f"⚪️白方:{white_nick}\n" f"\n" f"⏰每回合限时:{self.timeout}秒\n" f"\n" f"⚫️黑方先手!\n" f"\n" f"⚙️请发送: 下棋 坐标\n" f"例如: 下棋 C5" ) await bot.send_text_message(room_id, start_msg) # 发送棋盘 board_base64 = self._draw_board(game_id) await bot.send_image_message(room_id, board_base64) # 设置回合超时 game['timeout_task'] = asyncio.create_task( self._handle_turn_timeout(bot, game_id, game['black'], room_id) ) async def play_game(self, bot: WechatAPIClient, message: dict): """处理下棋操作""" error = '' room_id = message["FromWxid"] sender = message["SenderWxid"] if not message["IsGroup"]: error = '-----XYBot-----\n❌请在群聊中游玩五子棋' command = message["Content"].strip().split() if len(command) != 2: error = f'-----XYBot-----\n❌指令格式错误\n\n{self.command_format}' if error: await bot.send_text_message(message["FromWxid"], error) return if sender not in self.gomoku_players: await bot.send_text_message(room_id, '-----XYBot-----\n❌您不在任何游戏中!') return game_id = self.gomoku_players[sender] game = self.gomoku_games[game_id] if game['status'] != 'playing': await bot.send_text_message(room_id, '-----XYBot-----\n❌游戏已经结束!') return if sender != game['turn']: await bot.send_text_message(room_id, '-----XYBot-----\n❌还没到您的回合!') return # 解析坐标 coord = command[1].upper() if not (len(coord) >= 2 and coord[0] in 'ABCDEFGHIJKLMNOPQ' and coord[1:].isdigit()): await bot.send_text_message(room_id, '-----XYBot-----\n❌无效的坐标格式!') return x = ord(coord[0]) - ord('A') y = 16 - int(coord[1:]) if not (0 <= x <= 16 and 0 <= y <= 16): await bot.send_text_message(room_id, '-----XYBot-----\n❌坐标超出范围!') return if game['board'][y][x] != 0: await bot.send_text_message(room_id, '-----XYBot-----\n❌该位置已有棋子!') return # 取消超时任务 game['timeout_task'].cancel() # 落子 game['board'][y][x] = 1 if sender == game['black'] else 2 # 绘制并发送新棋盘 board_base64 = self._draw_board(game_id, highlight=(x, y)) await bot.send_image_message(room_id, board_base64) # 检查是否获胜 winner = self._check_winner(game_id) if winner: if winner == 'draw': await bot.send_text_message(room_id, f'-----XYBot-----\n🎉五子棋游戏 {game_id} 结束!\n\n平局!⚖️') else: winner_wxid = game['black'] if winner == 'black' else game['white'] winner_nick = await bot.get_nickname(winner_wxid) await bot.send_text_message( room_id, f'-----XYBot-----\n🎉五子棋游戏 {game_id} 结束!\n\n' f'{"⚫️黑方" if winner == "black" else "⚪️白方"}:{winner_nick} 获胜!🏆' ) # 清理游戏数据 self.gomoku_players.pop(game['black']) self.gomoku_players.pop(game['white']) self.gomoku_games.pop(game_id) return # 切换回合 game['turn'] = game['white'] if sender == game['black'] else game['black'] # 发送回合信息 current_nick = await bot.get_nickname(sender) next_nick = await bot.get_nickname(game['turn']) current_color = '⚫️' if sender == game['black'] else '⚪️' next_color = '⚫️' if game['turn'] == game['black'] else '⚪️' turn_msg = ( f"-----XYBot-----\n" f"{current_color}{current_nick} 把棋子落在了 {coord}!\n" f"轮到 {next_color}{next_nick} 下子了!\n" f"\n" f"⏰限时:{self.timeout}秒\n" f"\n" f"⚙️请发送: 下棋 坐标\n" f"例如: 下棋 C5" ) await bot.send_text_message(room_id, turn_msg) # 设置新的回合超时 game['timeout_task'] = asyncio.create_task( self._handle_turn_timeout(bot, game_id, game['turn'], room_id) ) def _generate_game_id(self) -> str: """生成游戏ID""" chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' while True: game_id = ''.join(sample(chars, 6)) if game_id not in self.gomoku_games: return game_id def _draw_board(self, game_id: str, highlight: tuple = None) -> str: """绘制棋盘并返回base64编码""" board_img = Image.open('resource/images/gomoku_board_original.png') draw = ImageDraw.Draw(board_img) board = self.gomoku_games[game_id]['board'] # 绘制棋子 for y in range(17): for x in range(17): if board[y][x] != 0: color = 'black' if board[y][x] == 1 else 'white' draw.ellipse( (24 + x * 27 - 8, 24 + y * 27 - 8, 24 + x * 27 + 8, 24 + y * 27 + 8), fill=color ) # 绘制高亮 if highlight: x, y = highlight draw.ellipse( (24 + x * 27 - 8, 24 + y * 27 - 8, 24 + x * 27 + 8, 24 + y * 27 + 8), outline='red', width=2 ) # 转换为bytes from io import BytesIO img_byte_arr = BytesIO() board_img.save(img_byte_arr, format='PNG') img_byte_arr = img_byte_arr.getvalue() # 转换为base64 return base64.b64encode(img_byte_arr).decode() def _check_winner(self, game_id: str) -> str: """检查是否有获胜者""" board = self.gomoku_games[game_id]['board'] # 检查所有方向 directions = [(0, 1), (1, 0), (1, 1), (1, -1)] for y in range(17): for x in range(17): if board[y][x] == 0: continue for dx, dy in directions: count = 1 nx, ny = x + dx, y + dy while (0 <= nx < 17 and 0 <= ny < 17 and board[ny][nx] == board[y][x]): count += 1 nx += dx ny += dy if count >= 5: return 'black' if board[y][x] == 1 else 'white' # 检查平局 if all(board[y][x] != 0 for y in range(17) for x in range(17)): return 'draw' return '' async def _handle_invite_timeout(self, bot: WechatAPIClient, game_id: str, inviter: str, invitee: str, room_id: str): """处理邀请超时""" await asyncio.sleep(self.timeout) if (game_id in self.gomoku_games and self.gomoku_games[game_id]['status'] == 'inviting'): # 清理游戏数据 self.gomoku_players.pop(inviter) self.gomoku_players.pop(invitee) self.gomoku_games.pop(game_id) await bot.send_at_message( room_id, f'-----XYBot-----\n❌五子棋游戏 {game_id} 邀请超时!', [inviter] ) async def _handle_turn_timeout(self, bot: WechatAPIClient, game_id: str, player: str, room_id: str): """处理回合超时""" await asyncio.sleep(self.timeout) if (game_id in self.gomoku_games and self.gomoku_games[game_id]['status'] == 'playing' and self.gomoku_games[game_id]['turn'] == player): game = self.gomoku_games[game_id] winner = game['white'] if player == game['black'] else game['black'] # 清理游戏数据 self.gomoku_players.pop(game['black']) self.gomoku_players.pop(game['white']) self.gomoku_games.pop(game_id) loser_nick = await bot.get_nickname(player) winner_nick = await bot.get_nickname(winner) await bot.send_text_message( room_id, f'-----XYBot-----\n' f'{loser_nick} 落子超时!\n' f'🏆 {winner_nick} 获胜!' ) ================================================ FILE: plugins/GoodMorning/__init__.py ================================================ ================================================ FILE: plugins/GoodMorning/config.toml ================================================ [GoodMorning] enable = true ================================================ FILE: plugins/GoodMorning/main.py ================================================ import asyncio import tomllib from datetime import datetime from random import randint import aiohttp from WechatAPI import WechatAPIClient from utils.decorators import * from utils.plugin_base import PluginBase class GoodMorning(PluginBase): description = "早上好插件" author = "HenryXiaoYang" version = "1.0.1" # Change Log # 1.0.1 fix ssl issue, add timeout def __init__(self): super().__init__() with open("plugins/GoodMorning/config.toml", "rb") as f: plugin_config = tomllib.load(f) config = plugin_config["GoodMorning"] self.enable = config["enable"] @schedule('cron', hour=8, minute=30) async def daily_task(self, bot: WechatAPIClient): if not self.enable: return id_list = [] wx_seq, chatroom_seq = 0, 0 while True: contact_list = await bot.get_contract_list(wx_seq, chatroom_seq) id_list.extend(contact_list["ContactUsernameList"]) wx_seq = contact_list["CurrentWxcontactSeq"] chatroom_seq = contact_list["CurrentChatRoomContactSeq"] if contact_list["CountinueFlag"] != 1: break chatrooms = [] for id in id_list: if id.endswith("@chatroom"): chatrooms.append(id) try: async with aiohttp.request("GET", "http://zj.v.api.aa1.cn/api/bk/?num=1&type=json", timeout=aiohttp.ClientTimeout(total=20)) as req: resp = await req.json() history_today = "无" if resp.get("content"): history_today = str(resp.get("content")[0]) except asyncio.TimeoutError: history_today = "🛜网络超时😭" weekend = ["一", "二", "三", "四", "五", "六", "日"] message = ("----- XYBot -----\n" f"[Sun]早上好!今天是 {datetime.now().strftime('%Y年%m月%d号')},星期{weekend[datetime.now().weekday()]}。\n" "\n" "📖历史上的今天:\n" f"{history_today}") for chatroom in chatrooms: await bot.send_text_message(chatroom, message) await asyncio.sleep(randint(1, 5)) ================================================ FILE: plugins/GroupWelcome/__init__.py ================================================ ================================================ FILE: plugins/GroupWelcome/config.toml ================================================ [GroupWelcome] enable = true welcome-message = "👆点我查看XYBot文档!" url = "https://henryxiaoyang.github.io/XYBotV2" ================================================ FILE: plugins/GroupWelcome/main.py ================================================ import tomllib import xml.etree.ElementTree as ET from datetime import datetime from loguru import logger from WechatAPI import WechatAPIClient from utils.decorators import on_system_message from utils.plugin_base import PluginBase class GroupWelcome(PluginBase): description = "进群欢迎" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/GroupWelcome/config.toml", "rb") as f: plugin_config = tomllib.load(f) config = plugin_config["GroupWelcome"] self.enable = config["enable"] self.welcome_message = config["welcome-message"] self.url = config["url"] @on_system_message async def group_welcome(self, bot: WechatAPIClient, message: dict): if not self.enable: return if not message["IsGroup"]: return xml_content = str(message["Content"]).strip().replace("\n", "").replace("\t", "") root = ET.fromstring(xml_content) if root.tag != "sysmsg": return # 检查是否是进群消息 if root.attrib.get("type") == "sysmsgtemplate": sys_msg_template = root.find("sysmsgtemplate") if sys_msg_template is None: return template = sys_msg_template.find("content_template") if template is None: return template_type = template.attrib.get("type") if template_type not in ["tmpl_type_profile", "tmpl_type_profilewithrevoke"]: return template_text = template.find("template").text if '"$names$"加入了群聊' in template_text: # 直接加入群聊 new_members = self._parse_member_info(root, "names") elif '"$username$"邀请"$names$"加入了群聊' in template_text: # 通过邀请加入群聊 new_members = self._parse_member_info(root, "names") elif '你邀请"$names$"加入了群聊' in template_text: # 自己邀请成员加入群聊 new_members = self._parse_member_info(root, "names") elif '"$adder$"通过扫描"$from$"分享的二维码加入群聊' in template_text: # 通过二维码加入群聊 new_members = self._parse_member_info(root, "adder") elif '"$adder$"通过"$from$"的邀请二维码加入群聊' in template_text: new_members = self._parse_member_info(root, "adder") else: logger.warning(f"未知的入群方式: {template_text}") return if not new_members: return for member in new_members: wxid = member["wxid"] nickname = member["nickname"] now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") profile = await bot.get_contact(wxid) await bot.send_link_message(message["FromWxid"], title=f"👏欢迎 {nickname} 加入群聊!🎉", description=f"⌚时间:{now}\n{self.welcome_message}", url=self.url, thumb_url=profile.get("BigHeadImgUrl", "") ) @staticmethod def _parse_member_info(root: ET.Element, link_name: str = "names") -> list[dict]: """解析新成员信息""" new_members = [] try: # 查找指定链接中的成员列表 names_link = root.find(f".//link[@name='{link_name}']") if names_link is None: return new_members memberlist = names_link.find("memberlist") if memberlist is None: return new_members for member in memberlist.findall("member"): username = member.find("username").text nickname = member.find("nickname").text new_members.append({ "wxid": username, "nickname": nickname }) except Exception as e: logger.warning(f"解析新成员信息失败: {e}") return new_members ================================================ FILE: plugins/Leaderboard/__init__.py ================================================ ================================================ FILE: plugins/Leaderboard/config.toml ================================================ [Leaderboard] enable = true command = ["排行榜", "积分榜", "积分排行榜", "群排行榜", "群积分榜"] max-count = 30 ================================================ FILE: plugins/Leaderboard/main.py ================================================ import asyncio import tomllib from random import choice from WechatAPI import WechatAPIClient from database.XYBotDB import XYBotDB from utils.decorators import * from utils.plugin_base import PluginBase class Leaderboard(PluginBase): description = "积分榜" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/Leaderboard/config.toml", "rb") as f: plugin_config = tomllib.load(f) config = plugin_config["Leaderboard"] self.enable = config["enable"] self.command = config["command"] self.max_count = config["max-count"] self.db = XYBotDB() @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if command[0] not in self.command: return if "群" in command[0]: chatroom_members = await bot.get_chatroom_member_list(message["FromWxid"]) data = [] for member in chatroom_members: wxid = member["UserName"] points = self.db.get_points(wxid) if points == 0: continue data.append((member["NickName"], points)) data.sort(key=lambda x: x[1], reverse=True) data = data[:self.max_count] out_message = "-----XYBot积分群排行榜-----" rank_emojis = ["👑", "🥈", "🥉"] for rank, (nickname, points) in enumerate(data, start=1): emoji = rank_emojis[rank - 1] if rank <= 3 else "" random_emoji = choice( ["😄", "😃", "😁", "😆", "😊", "😍", "😋", "😎", "🤗", "😺", "🥳", "🤩", "🎉", "⭐", "🎊", "🎈", "🌟", "✨", "🎶", "❤️", "😛"]) out_message += f"\n{emoji}{'' if emoji else str(rank) + '.'} {nickname} {points}分 {random_emoji}" else: data = self.db.get_leaderboard(self.max_count) wxids = [i[0] for i in data] nicknames = [] async def get_nicknames_chunk(chunk_wxids): return await bot.get_nickname(chunk_wxids) # 将wxids分成每组20个 chunks = [wxids[i:i + 20] for i in range(0, len(wxids), 20)] # 使用信号量限制并发数为2 sem = asyncio.Semaphore(2) async def worker(chunk): async with sem: return await get_nicknames_chunk(chunk) # 并发执行所有请求 tasks = [worker(chunk) for chunk in chunks] results = await asyncio.gather(*tasks) # 将所有结果合并到nicknames列表中 for result in results: nicknames.extend(result) out_message = "-----XYBot积分排行榜-----" rank_emojis = ["👑", "🥈", "🥉"] for rank, (i, nickname) in enumerate(zip(data, nicknames), start=1): wxid, points = i nickname = nickname or wxid emoji = rank_emojis[rank - 1] if rank <= 3 else "" random_emoji = choice( ["😄", "😃", "😁", "😆", "😊", "😍", "😋", "😎", "🤗", "😺", "🥳", "🤩", "🎉", "⭐", "🎊", "🎈", "🌟", "✨", "🎶", "❤️", "😛"]) out_message += f"\n{emoji}{'' if emoji else str(rank) + '.'} {nickname} {points}分 {random_emoji}" await bot.send_text_message(message["FromWxid"], out_message) ================================================ FILE: plugins/LuckyDraw/__init__.py ================================================ ================================================ FILE: plugins/LuckyDraw/config.toml ================================================ [LuckyDraw] enable = true command = ["抽奖", "幸运抽奖", "抽奖活动", "抽奖指令", "幸运大抽奖", "大抽奖", "积分抽奖"] command-format = """ -----XYBot----- 🎉抽奖指令: 抽奖 奖池名 次数 次数可选 🎁奖池列表: 小抽奖指令:抽奖 小 中抽奖指令:抽奖 中 大抽奖指令:抽奖 大 """ max-draw = 100 draw-per-guarantee = 10 guaranteed-max-probability = 0.5 [LuckyDraw.probabilities.small] name = "小" cost = 20 [LuckyDraw.probabilities.small.probability] "0.05" = { name = "金", points = 40, symbol = "🟨" } "0.10" = { name = "紫", points = 35, symbol = "🟪" } "0.20" = { name = "蓝", points = 21, symbol = "🟦" } "0.30" = { name = "绿", points = 15, symbol = "🟩" } "0.35" = { name = "白", points = 10, symbol = "⬜️" } [LuckyDraw.probabilities.medium] name = "中" cost = 40 [LuckyDraw.probabilities.medium.probability] "0.05" = { name = "金", points = 70, symbol = "🟨" } "0.10" = { name = "紫", points = 55, symbol = "🟪" } "0.20" = { name = "蓝", points = 41, symbol = "🟦" } "0.30" = { name = "绿", points = 35, symbol = "🟩" } "0.35" = { name = "白", points = 25, symbol = "⬜️" } [LuckyDraw.probabilities.large] name = "大" cost = 80 [LuckyDraw.probabilities.large.probability] "0.01" = { name = "红", points = 170, symbol = "🟥" } "0.05" = { name = "金", points = 120, symbol = "🟨" } "0.10" = { name = "紫", points = 90, symbol = "🟪" } "0.20" = { name = "蓝", points = 81, symbol = "🟦" } "0.30" = { name = "绿", points = 75, symbol = "🟩" } "0.34" = { name = "白", points = 65, symbol = "⬜️" } ================================================ FILE: plugins/LuckyDraw/main.py ================================================ import random import tomllib from loguru import logger from WechatAPI import WechatAPIClient from database.XYBotDB import XYBotDB from utils.decorators import * from utils.plugin_base import PluginBase class LuckyDraw(PluginBase): description = "幸运抽奖" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/LuckyDraw/config.toml", "rb") as f: plugin_config = tomllib.load(f) config = plugin_config["LuckyDraw"] self.enable = config["enable"] self.command = config["command"] self.command_format = config["command-format"] probabilities = config["probabilities"] self.probabilities = {} for item in probabilities.values(): name = item["name"] cost = item["cost"] probability = item["probability"] self.probabilities[name] = {"cost": cost, "probability": probability} self.max_draw = config["max-draw"] self.draw_per_guarantee = config["draw-per-guarantee"] self.guaranteed_max_probability = config["guaranteed-max-probability"] self.db = XYBotDB() @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if not len(command) or command[0] not in self.command: return target_wxid = message["SenderWxid"] target_points = self.db.get_points(target_wxid) if len(command) < 2: await bot.send_at_message(message["FromWxid"], self.command_format, [target_wxid]) return draw_name = command[1] if draw_name not in self.probabilities.keys(): await bot.send_text_message(message["FromWxid"], "-----XYBot-----\n🤔你指定的奖池无效哦!") return draw_count = 1 if len(command) == 3 and command[2].isdigit(): draw_count = int(command[2]) if draw_count > self.max_draw: await bot.send_text_message(message["FromWxid"], f"-----XYBot-----\n😔你最多只能抽{self.max_draw}次哦!") return if target_points < self.probabilities[draw_name]["cost"] * draw_count: await bot.send_text_message(message["FromWxid"], f"-----XYBot-----\n😭你积分不足以你抽{draw_count}次{draw_name}抽奖哦!") return draw_probability = self.probabilities[draw_name]["probability"] cost = self.probabilities[draw_name]["cost"] * draw_count self.db.add_points(target_wxid, -cost) wins = [] # 保底抽奖 min_guaranteed = draw_count // self.draw_per_guarantee # 保底抽奖次数 for _ in range(min_guaranteed): # 先把保底抽了 random_num = random.uniform(0, self.guaranteed_max_probability) cumulative_probability = 0 for p, prize in draw_probability.items(): cumulative_probability += float(p) if random_num <= cumulative_probability: win_name = prize["name"] win_points = prize["points"] win_symbol = prize["symbol"] wins.append( (win_name, win_points, win_symbol) ) # 把结果加入赢取列表 break # 正常抽奖 for _ in range(draw_count - min_guaranteed): # 把剩下的抽了 random_num = random.uniform(0, 1) cumulative_probability = 0 for p, prize in draw_probability.items(): cumulative_probability += float(p) if random_num <= cumulative_probability: win_name = prize["name"] win_points = prize["points"] win_symbol = prize["symbol"] wins.append( (win_name, win_points, win_symbol) ) # 把结果加入赢取列表 break total_win_points = 0 for win_name, win_points, win_symbol in wins: # 统计赢取的积分 total_win_points += win_points self.db.add_points(target_wxid, total_win_points) # 把赢取的积分加入数据库 logger.info(f"用户 {target_wxid} 在 {draw_name} 抽了 {draw_count}次 赢取了{total_win_points}积分") output = self.make_message(wins, draw_name, draw_count, total_win_points, cost) await bot.send_at_message(message["FromWxid"], output, [target_wxid]) @staticmethod def make_message( wins, draw_name, draw_count, total_win_points, draw_cost ): # 组建信息 name_max_len = 0 for win_name, win_points, win_symbol in wins: if len(win_name) > name_max_len: name_max_len = len(win_name) begin_message = f"\n----XYBot抽奖----\n🥳恭喜你在 {draw_count}次 {draw_name}抽奖 中抽到了:\n\n" lines = [] for _ in range(name_max_len + 2): lines.append("") begin_line = 0 one_line_length = 0 for win_name, win_points, win_symbol in wins: if one_line_length >= 10: # 每行10个结果,以免在微信上格式错误 begin_line += name_max_len + 2 for _ in range(name_max_len + 2): lines.append("") # 占个位 one_line_length = 0 lines[begin_line] += win_symbol for i in range(begin_line + 1, begin_line + name_max_len + 1): if i % (name_max_len + 2) <= len(win_name): lines[i] += ( "\u2004" + win_name[i % (name_max_len + 2) - 1] ) # \u2004 这个空格最好 试过了很多种空格 else: lines[i] += win_symbol lines[begin_line + name_max_len + 1] += win_symbol one_line_length += 1 message = begin_message for line in lines: message += line + "\n" message += f"\n\n🎉总计赢取积分: {total_win_points}🎉\n🎉共计消耗积分:{draw_cost}🎉\n\n概率请自行查询菜单⚙️" return message ================================================ FILE: plugins/ManagePlugin/__init__.py ================================================ ================================================ FILE: plugins/ManagePlugin/config.toml ================================================ [ManagePlugin] command = ["加载插件", "加载所有插件", "卸载插件", "卸载所有插件", "重载插件", "重载所有插件", "插件列表", "插件信息"] ================================================ FILE: plugins/ManagePlugin/main.py ================================================ import tomllib from tabulate import tabulate from WechatAPI import WechatAPIClient from database.XYBotDB import XYBotDB from utils.decorators import * from utils.plugin_base import PluginBase from utils.plugin_manager import PluginManager class ManagePlugin(PluginBase): description = "插件管理器" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() self.db = XYBotDB() with open("plugins/ManagePlugin/config.toml", "rb") as f: plugin_config = tomllib.load(f) with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) plugin_config = plugin_config["ManagePlugin"] main_config = main_config["XYBot"] self.command = plugin_config["command"] self.admins = main_config["admins"] self.plugin_manager = PluginManager() @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): content = str(message["Content"]).strip() command = content.split(" ") if not len(command) or command[0] not in self.command: return if message["SenderWxid"] not in self.admins: await bot.send_text_message(message["FromWxid"], "你没有权限使用此命令") return plugin_name = command[1] if len(command) > 1 else None if command[0] == "加载插件": if plugin_name in self.plugin_manager.plugins.keys(): await bot.send_text_message(message["FromWxid"], "⚠️插件已经加载") return attempt = await self.plugin_manager.load_plugin(plugin_name) if attempt: await bot.send_text_message(message["FromWxid"], f"✅插件 {plugin_name} 加载成功") else: await bot.send_text_message(message["FromWxid"], f"❌插件 {plugin_name} 加载失败,请查看日志错误信息") elif command[0] == "加载所有插件": attempt = await self.plugin_manager.load_plugins(load_disabled=True) if isinstance(attempt, list): attempt = '\n'.join(attempt) await bot.send_text_message(message["FromWxid"], f"✅插件加载成功:\n{attempt}") else: await bot.send_text_message(message["FromWxid"], "❌插件加载失败,请查看日志错误信息") elif command[0] == "卸载插件": if plugin_name == "ManagePlugin": await bot.send_text_message(message["FromWxid"], "⚠️你不能卸载 ManagePlugin 插件!") return elif plugin_name not in self.plugin_manager.plugins.keys(): await bot.send_text_message(message["FromWxid"], "⚠️插件不存在或未加载") return attempt = await self.plugin_manager.unload_plugin(plugin_name) if attempt: await bot.send_text_message(message["FromWxid"], f"✅插件 {plugin_name} 卸载成功") else: await bot.send_text_message(message["FromWxid"], f"❌插件 {plugin_name} 卸载失败,请查看日志错误信息") elif command[0] == "卸载所有插件": unloaded_plugins, failed_unloads = await self.plugin_manager.unload_plugins() unloaded_plugins = '\n'.join(unloaded_plugins) failed_unloads = '\n'.join(failed_unloads) await bot.send_text_message(message["FromWxid"], f"✅插件卸载成功:\n{unloaded_plugins}\n❌插件卸载失败:\n{failed_unloads}") elif command[0] == "重载插件": if plugin_name == "ManagePlugin": await bot.send_text_message(message["FromWxid"], "⚠️你不能重载 ManagePlugin 插件!") return elif plugin_name not in self.plugin_manager.plugins.keys(): await bot.send_text_message(message["FromWxid"], "⚠️插件不存在或未加载") return attempt = await self.plugin_manager.reload_plugin(plugin_name) if attempt: await bot.send_text_message(message["FromWxid"], f"✅插件 {plugin_name} 重载成功") else: await bot.send_text_message(message["FromWxid"], f"❌插件 {plugin_name} 重载失败,请查看日志错误信息") elif command[0] == "重载所有插件": attempt = await self.plugin_manager.reload_plugins() if attempt: await bot.send_text_message(message["FromWxid"], "✅所有插件重载成功") else: await bot.send_text_message(message["FromWxid"], "❌插件重载失败,请查看日志错误信息") elif command[0] == "插件列表": plugin_list = self.plugin_manager.get_plugin_info() plugin_stat = [["插件名称", "是否启用"]] for plugin in plugin_list: plugin_stat.append([plugin['name'], "✅" if plugin['enabled'] else "🚫"]) table = str(tabulate(plugin_stat, headers="firstrow", tablefmt="simple")) await bot.send_text_message(message["FromWxid"], table) elif command[0] == "插件信息": attemt = self.plugin_manager.get_plugin_info(plugin_name) if isinstance(attemt, dict): output = (f"插件名称: {attemt['name']}\n" f"插件描述: {attemt['description']}\n" f"插件作者: {attemt['author']}\n" f"插件版本: {attemt['version']}") await bot.send_text_message(message["FromWxid"], output) else: await bot.send_text_message(message["FromWxid"], "⚠️插件不存在或未加载") ================================================ FILE: plugins/Menu/__init__.py ================================================ ================================================ FILE: plugins/Menu/config.toml ================================================ [Menu] enable = true command = ["菜单", "帮助", "帮助菜单", "功能列表", "功能菜单", "指令列表", "指令菜单", "功能", "指令", "cd", "Cd", "cd", "menu", "Menu"] menu = """-======== XYBot ========- 🤖AI聊天🤖 (可指令可群@可私信语音可私信图片) ☁️天气☁️ 🎵点歌🎵 📰新闻📰 ♟️五子棋♟️ 📰随机新闻📰 🏞️随机图片🏞️ 🔢随机群员🔢 🎮战雷查询🎮 ✅签到✅ 💰积分查询💰 🏆积分榜🏆 🏆群积分榜🏆 🤝积分交易🤝 🤑积分抽奖🤑 🧧积分红包🧧 ⚙️查看管理员菜单请发送:管理员菜单""" admin-menu = """-----XYBot----- ⚙️管理积分: ➕积分: 加积分 积分 wxid/@用户 ➖积分: 减积分 积分 wxid/@用户 🔢设置积分: 设置积分 积分 wxid/@用户 ⚙️管理白名单 ➕白名单: 添加白名单 wxid/@用户 ➖白名单: 删除白名单 wxid/@用户 📋白名单列表: 白名单列表 ⚙️重置签到状态: 重置签到 ‼️‼️管理插件‼️‼️ ⚙️加载插件: 加载插件 插件名 ⚙️加载所有插件: 加载所有插件 ⚙️卸载插件: 卸载插件 插件名 ⚙️卸载所有插件: 卸载所有插件 ⚙️重载插件: 重载插件 插件名 ⚙️重载所有插件: 重载所有插件 ⚙️插件列表: 插件列表""" ================================================ FILE: plugins/Menu/main.py ================================================ import tomllib from WechatAPI import WechatAPIClient from utils.decorators import * from utils.plugin_base import PluginBase class Menu(PluginBase): description = "菜单" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/Menu/config.toml", "rb") as f: plugin_config = tomllib.load(f) with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) config = plugin_config["Menu"] main_config = main_config["XYBot"] self.enable = config["enable"] self.command = config["command"] self.menu = config["menu"] self.admin_menu = config["admin-menu"] self.version = main_config["version"] @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if command[0] in self.command: menu = (f"\n" f"{self.menu}\n" f"\n" f"来自XYBotV2 {self.version}\n" f"https://github.com/HenryXiaoYang/XYBotV2") await bot.send_at_message(message["FromWxid"], menu, [message["SenderWxid"]]) elif command[0] == "管理员菜单": await bot.send_at_message(message["FromWxid"], self.admin_menu, [message["SenderWxid"]]) ================================================ FILE: plugins/Music/__init__.py ================================================ ================================================ FILE: plugins/Music/config.toml ================================================ [Music] enable = true command = ["点歌", "音乐", "音乐点播", "点播音乐", "音乐点歌"] command-format = """ -----XYBot----- 🎵点歌指令: 点歌 歌曲名 """ ================================================ FILE: plugins/Music/main.py ================================================ import tomllib import aiohttp from WechatAPI import WechatAPIClient from utils.decorators import * from utils.plugin_base import PluginBase class Music(PluginBase): description = "点歌" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/Music/config.toml", "rb") as f: plugin_config = tomllib.load(f) config = plugin_config["Music"] self.enable = config["enable"] self.command = config["command"] self.command_format = config["command-format"] @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if command[0] not in self.command: return if len(command) == 1: await bot.send_at_message(message["FromWxid"], f"-----XYBot-----\n❌命令格式错误!{self.command_format}", [message["SenderWxid"]]) return song_name = content[len(command[0]):].strip() async with aiohttp.ClientSession() as session: async with session.get( f"https://www.hhlqilongzhu.cn/api/dg_wyymusic.php?gm={song_name}&n=1&br=2&type=json") as resp: data = await resp.json() if data["code"] != 200: await bot.send_at_message(message["FromWxid"], f"-----XYBot-----\n❌点歌失败!\n{data}", [message["SenderWxid"]]) return title = data["title"] singer = data["singer"] url = data["link"] music_url = data["music_url"].split("?")[0] cover_url = data["cover"] lyric = data["lrc"] xml = f"""{title}{singer}view30{url}{music_url}{url}{music_url}{cover_url}{lyric}000{cover_url}{bot.wxid}01""" await bot.send_app_message(message["FromWxid"], xml, 3) ================================================ FILE: plugins/News/__init__.py ================================================ ================================================ FILE: plugins/News/config.toml ================================================ [News] enable = true enable-schedule-news = false # 是否每天早上和中午自动推送新闻? command = ["新闻", "新闻速递", "新闻资讯", "新闻头条", "新闻列表", "随机新闻", "获取新闻", "获取随机新闻", "头条", "头条新闻", "今日头条", "今日新闻"] ================================================ FILE: plugins/News/main.py ================================================ import asyncio import tomllib from random import choice import aiohttp from WechatAPI import WechatAPIClient from utils.decorators import * from utils.plugin_base import PluginBase class News(PluginBase): description = "新闻插件" author = "HenryXiaoYang" version = "1.1.0" # Change Log # 1.1.0 2025/2/22 默认关闭定时新闻 def __init__(self): super().__init__() with open("plugins/News/config.toml", "rb") as f: plugin_config = tomllib.load(f) config = plugin_config["News"] self.enable = config["enable"] self.enable_schedule_news = config["enable-schedule-news"] self.command = config["command"] @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if command[0] not in self.command: return if "随机" in command[0]: async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: async with session.get("https://cn.apihz.cn/api/xinwen/baidu.php?id=88888888&key=88888888") as resp: data = await resp.json() if data["code"] != 200: await bot.send_text_message(message["FromWxid"], "-----XYBot-----\n新闻获取失败!") return result = data.get("data", []) if len(result) == 0: await bot.send_text_message(message["FromWxid"], "-----XYBot-----\n新闻获取失败!") return new = choice(result["data"]) await bot.send_link_message(message["FromWxid"], title=new["word"], url=new["rawUrl"], description=new["desc"], thumb_url=new["img"]) else: async with aiohttp.ClientSession() as session: async with session.get("http://zj.v.api.aa1.cn/api/60s-v2/?cc=XYBot") as resp: image_byte = await resp.read() await bot.send_image_message(message["FromWxid"], image_byte) @schedule('cron', hour=12) async def noon_news(self, bot: WechatAPIClient): if not self.enable_schedule_news: return id_list = [] wx_seq, chatroom_seq = 0, 0 while True: contact_list = await bot.get_contract_list(wx_seq, chatroom_seq) id_list.extend(contact_list["ContactUsernameList"]) wx_seq = contact_list["CurrentWxcontactSeq"] chatroom_seq = contact_list["CurrentChatRoomContactSeq"] if contact_list["CountinueFlag"] != 1: break chatrooms = [] for id in id_list: if id.endswith("@chatroom"): chatrooms.append(id) async with aiohttp.ClientSession() as session: async with session.get("http://zj.v.api.aa1.cn/api/60s-v2/?cc=XYBot") as resp: iamge_byte = await resp.read() for id in chatrooms: await bot.send_image_message(id, iamge_byte) await asyncio.sleep(2) @schedule('cron', hour=18) async def night_news(self, bot: WechatAPIClient): if not self.enable_schedule_news: return id_list = [] wx_seq, chatroom_seq = 0, 0 while True: contact_list = await bot.get_contract_list(wx_seq, chatroom_seq) id_list.extend(contact_list["ContactUsernameList"]) wx_seq = contact_list["CurrentWxcontactSeq"] chatroom_seq = contact_list["CurrentChatRoomContactSeq"] if contact_list["CountinueFlag"] != 1: break chatrooms = [] for id in id_list: if id.endswith("@chatroom"): chatrooms.append(id) async with aiohttp.ClientSession() as session: async with session.get("http://v.api.aa1.cn/api/60s-v3/?cc=XYBot") as resp: iamge_byte = await resp.read() for id in chatrooms: await bot.send_image_message(id, iamge_byte) await asyncio.sleep(2) ================================================ FILE: plugins/PointTrade/__init__.py ================================================ ================================================ FILE: plugins/PointTrade/config.toml ================================================ [PointTrade] enable = true command = ["积分交易", "积分转账", "转账积分", "积分赠送", "赠送积分", "积分转移", "转移积分", "送积分", "积分送人", "送人积分", "积分赠予", "赠予"] command-format = """ -----XYBot----- 🔄转账积分: 积分转账 积分数 @用户 """ ================================================ FILE: plugins/PointTrade/main.py ================================================ import tomllib from datetime import datetime from WechatAPI import WechatAPIClient from database.XYBotDB import XYBotDB from utils.decorators import * from utils.plugin_base import PluginBase class PointTrade(PluginBase): description = "积分交易" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/PointTrade/config.toml", "rb") as f: plugin_config = tomllib.load(f) config = plugin_config["PointTrade"] self.enable = config["enable"] self.command = config["command"] self.command_format = config["command-format"] self.db = XYBotDB() @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if command[0] not in self.command: return if len(command) < 3: await bot.send_at_message(message["FromWxid"], self.command_format, [message["SenderWxid"]]) return elif not command[1].isdigit(): await bot.send_at_message(message["FromWxid"], "\n-----XYBot-----\n🈚️转账积分无效(必须为正整数!)", [message["SenderWxid"]]) return elif len(message["Ats"]) != 1: await bot.send_at_message(message["FromWxid"], "-----XYBot-----\n转账失败❌\n🈚️转账人无效!", [message["SenderWxid"]]) return points = int(command[1]) target_wxid = message["Ats"][0] trader_wxid = message["SenderWxid"] # check points trader_points = self.db.get_points(trader_wxid) if trader_points < points: await bot.send_at_message(message["FromWxid"], "\n-----XYBot-----\n转账失败❌\n积分不足!😭", [message["SenderWxid"]]) return self.db.safe_trade_points(trader_wxid, target_wxid, points) trader_nick, target_nick = await bot.get_nickname([trader_wxid, target_wxid]) trader_points = self.db.get_points(trader_wxid) target_points = self.db.get_points(target_wxid) output = ( f"\n-----XYBot-----\n" f"✅积分转账成功!✨\n" f"🤝{trader_nick} 现在有 {trader_points} 点积分➖\n" f"🤝{target_nick} 现在有 {target_points} 点积分➕\n" f"⌚️时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" ) await bot.send_at_message(message["FromWxid"], output, [trader_wxid, target_wxid]) ================================================ FILE: plugins/QueryPoint/__init__.py ================================================ ================================================ FILE: plugins/QueryPoint/config.toml ================================================ [QueryPoint] enable = true command = ["查询积分", "积分", "我的积分", "积分查询"] ================================================ FILE: plugins/QueryPoint/main.py ================================================ import tomllib from WechatAPI import WechatAPIClient from database.XYBotDB import XYBotDB from utils.decorators import * from utils.plugin_base import PluginBase class QueryPoint(PluginBase): description = "查询积分" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/QueryPoint/config.toml", "rb") as f: plugin_config = tomllib.load(f) config = plugin_config["QueryPoint"] self.enable = config["enable"] self.command = config["command"] self.db = XYBotDB() @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if not len(command) or command[0] not in self.command: return query_wxid = message["SenderWxid"] points = self.db.get_points(query_wxid) output = ("\n" f"-----XYBot-----\n" f"你有 {points} 点积分!😄") await bot.send_at_message(message["FromWxid"], output, [query_wxid]) ================================================ FILE: plugins/RandomMember/__init__.py ================================================ ================================================ FILE: plugins/RandomMember/config.toml ================================================ [RandomMember] enable = true command = ["随机成员", "随机群员", "随机群成员", "随机群用户"] count = 3 ================================================ FILE: plugins/RandomMember/main.py ================================================ import random import tomllib from WechatAPI import WechatAPIClient from utils.decorators import * from utils.plugin_base import PluginBase class RandomMember(PluginBase): description = "随机群成员" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/RandomMember/config.toml", "rb") as f: plugin_config = tomllib.load(f) config = plugin_config["RandomMember"] self.enable = config["enable"] self.command = config["command"] self.count = config["count"] @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if command[0] not in self.command: return if not message["IsGroup"]: await bot.send_text_message(message["FromWxid"], "-----XYBot-----\n😠只能在群里使用!") return memlist = await bot.get_chatroom_member_list(message["FromWxid"]) random_members = random.sample(memlist, self.count) output = "\n-----XYBot-----\n👋嘿嘿,我随机选到了这几位:" for member in random_members: output += f"\n✨{member['NickName']}" await bot.send_at_message(message["FromWxid"], output, [message["SenderWxid"]]) ================================================ FILE: plugins/RandomPicture/__init__.py ================================================ ================================================ FILE: plugins/RandomPicture/config.toml ================================================ [RandomPicture] enable = true command = ["随机图片", "随机图图"] ================================================ FILE: plugins/RandomPicture/main.py ================================================ import tomllib import traceback import aiohttp from loguru import logger from WechatAPI import WechatAPIClient from utils.decorators import * from utils.plugin_base import PluginBase class RandomPicture(PluginBase): description = "随机图片" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/RandomPicture/config.toml", "rb") as f: plugin_config = tomllib.load(f) config = plugin_config["RandomPicture"] self.enable = config["enable"] self.command = config["command"] @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if not len(command) or command[0] not in self.command: return api_url = "https://api.52vmy.cn/api/img/tu/man?type=text" try: conn_ssl = aiohttp.TCPConnector(ssl=False) async with aiohttp.request("GET", url=api_url, connector=conn_ssl) as req: pic_url = await req.text() async with aiohttp.request("GET", url=pic_url, connector=conn_ssl) as req: content = await req.read() await conn_ssl.close() await bot.send_image_message(message["FromWxid"], image=content) except Exception as error: out_message = f"-----XYBot-----\n出现错误❌!\n{error}" logger.error(traceback.format_exc()) await bot.send_text_message(message["FromWxid"], out_message) ================================================ FILE: plugins/RedPacket/__init__.py ================================================ ================================================ FILE: plugins/RedPacket/config.toml ================================================ [RedPacket] enable = true command-format = """⚙️红包系统: 发红包 积分 数量 抢红包 口令""" max-point = 1000 min-point = 1 max-packet = 10 max-time = 300 ================================================ FILE: plugins/RedPacket/main.py ================================================ import base64 import random import re import time import tomllib from io import BytesIO from PIL import Image, ImageDraw, ImageFilter from captcha.image import ImageCaptcha from loguru import logger from WechatAPI import WechatAPIClient from database.XYBotDB import XYBotDB from utils.decorators import * from utils.plugin_base import PluginBase class RedPacket(PluginBase): description = "红包系统" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/RedPacket/config.toml", "rb") as f: plugin_config = tomllib.load(f) config = plugin_config["RedPacket"] self.enable = config["enable"] self.command_format = config["command-format"] self.max_point = config["max-point"] self.min_point = config["min-point"] self.max_packet = config["max-packet"] self.max_time = config["max-time"] self.red_packets = {} self.db = XYBotDB() @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = re.split(r'[\s\u2005]+', content) if not len(command): return if len(command) == 3 and command[0] == "发红包": await self.send_red_packet(bot, message, command) elif len(command) == 2 and command[0] == "抢红包": await self.grab_red_packet(bot, message, command) elif command[0] in ["发红包", "抢红包"]: await bot.send_text_message(message["FromWxid"], f"-----XYBot-----\n{self.command_format}") async def send_red_packet(self, bot: WechatAPIClient, message: dict, command: list): sender_wxid = message["SenderWxid"] from_wxid = message["FromWxid"] error = "" if not message["IsGroup"]: error = "\n-----XYBot-----\n红包只能在群里发!😔" elif not command[1].isdigit() or not command[2].isdigit(): error = f"\n-----XYBot-----\n指令格式错误!\n{self.command_format}" elif int(command[1]) > self.max_point or int(command[1]) < self.min_point: error = f"\n-----XYBot-----\n⚠️积分无效!最大{self.max_point},最小{self.min_point}!" elif int(command[2]) > self.max_packet: error = f"\n-----XYBot-----\n⚠️红包数量无效!最大{self.max_packet}个红包!" elif int(command[2]) > int(command[1]): error = "\n-----XYBot-----\n🔢红包数量不能大于红包积分!" elif self.db.get_points(sender_wxid) < int(command[1]): error = "\n-----XYBot-----\n😭你的积分不够!" if error: await bot.send_at_message(from_wxid, error, [sender_wxid]) return points = int(command[1]) amount = int(command[2]) sender_nick = await bot.get_nickname(sender_wxid) points_list = self._split_integer(points, amount) # 生成验证码图片 captcha, captcha_image = self._generate_captcha() # 加载红包背景图 background = Image.open("resource/images/redpacket.png") # 调整验证码图片大小 captcha_width = 400 # 进一步增加验证码宽度 captcha_height = 150 # 进一步增加验证码高度 captcha_image = captcha_image.resize((captcha_width, captcha_height)) # 创建一个带有圆角矩形和模糊边缘效果的遮罩 padding = 40 # 增加边缘空间 mask = Image.new('L', (captcha_width + padding * 2, captcha_height + padding * 2), 0) draw = ImageDraw.Draw(mask) # 绘制圆角矩形 radius = 20 # 圆角半径 draw.rounded_rectangle( [padding, padding, captcha_width + padding, captcha_height + padding], radius=radius, fill=255 ) # 应用高斯模糊创建柔和边缘 mask = mask.filter(ImageFilter.GaussianBlur(radius=20)) # 创建一个新的白色背景图层用于验证码 captcha_layer = Image.new('RGBA', (captcha_width + padding * 2, captcha_height + padding * 2), (255, 255, 255, 0)) # 将验证码图片粘贴到图层的中心 captcha_layer.paste(captcha_image, (padding, padding)) # 应用模糊遮罩 captcha_layer.putalpha(mask) # 计算验证码位置使其在橙色区域居中 x = (background.width - (captcha_width + padding * 2)) // 2 y = background.height - 320 # 调整位置 # 将带有模糊边缘的验证码图片粘贴到背景图 background.paste(captcha_layer, (x, y), captcha_layer) # 转换为base64 buffer = BytesIO() background.save(buffer, format='PNG') image_base64 = base64.b64encode(buffer.getvalue()).decode() # 保存红包信息 self.red_packets[captcha] = { "points": points, "amount": amount, "sender": sender_wxid, "list": points_list, "grabbed": [], "time": time.time(), "chatroom": from_wxid, "sender_nick": sender_nick } self.db.add_points(sender_wxid, -points) logger.info(f"用户 {sender_wxid} 发了个红包 {captcha},总计 {points} 点积分") # 发送文字消息和图片 text_content = ( f"-----XYBot-----\n" f"✨{sender_nick} 发送了一个红包!🧧\n" f"🥳快输入指令来抢红包!🎉\n" f"🧧指令:抢红包 口令" ) await bot.send_text_message(from_wxid, text_content) await bot.send_image_message(from_wxid, image_base64) async def grab_red_packet(self, bot: WechatAPIClient, message: dict, command: list): grabber_wxid = message["SenderWxid"] from_wxid = message["FromWxid"] captcha = command[1] error = "" if captcha not in self.red_packets: error = "\n-----XYBot-----\n❌红包口令错误!" elif not self.red_packets[captcha]["list"]: error = "\n-----XYBot-----\n😭红包已被抢完!" elif not message["IsGroup"]: error = "\n-----XYBot-----\n红包只能在群里抢!😔" elif grabber_wxid in self.red_packets[captcha]["grabbed"]: error = "\n-----XYBot-----\n你已经抢过这个红包了!😡" elif self.red_packets[captcha]["sender"] == grabber_wxid: error = "\n-----XYBot-----\n😠不能抢自己的红包!" if error: await bot.send_at_message(from_wxid, error, [grabber_wxid]) return try: grabbed_points = self.red_packets[captcha]["list"].pop() self.red_packets[captcha]["grabbed"].append(grabber_wxid) grabber_nick = await bot.get_nickname(grabber_wxid) self.db.add_points(grabber_wxid, grabbed_points) out_message = f"-----XYBot-----\n🧧恭喜 {grabber_nick} 抢到了 {grabbed_points} 点积分!👏" await bot.send_text_message(from_wxid, out_message) if not self.red_packets[captcha]["list"]: self.red_packets.pop(captcha) except IndexError: await bot.send_at_message(from_wxid, "\n-----XYBot-----\n红包已被抢完!😭", [grabber_wxid]) @schedule('interval', seconds=300) async def check_expired_packets(self, bot: WechatAPIClient): logger.info("[计划任务]检查是否有超时的红包") for captcha in list(self.red_packets.keys()): packet = self.red_packets[captcha] if time.time() - packet["time"] > self.max_time: points_left = sum(packet["list"]) sender_wxid = packet["sender"] chatroom = packet["chatroom"] sender_nick = packet["sender_nick"] self.db.add_points(sender_wxid, points_left) self.red_packets.pop(captcha) out_message = ( f"-----XYBot-----\n" f"🧧发现有红包 {captcha} 超时!已归还剩余 {points_left} 积分给 {sender_nick}" ) await bot.send_text_message(chatroom, out_message) @staticmethod def _generate_captcha(): chars = "abdfghkmnpqtwxy23467889" captcha = ''.join(random.sample(chars, 5)) image = ImageCaptcha().generate_image(captcha) return captcha, image @staticmethod def _split_integer(num: int, count: int) -> list: result = [1] * count remaining = num - count while remaining > 0: index = random.randint(0, count - 1) result[index] += 1 remaining -= 1 return result ================================================ FILE: plugins/SignIn/__init__.py ================================================ ================================================ FILE: plugins/SignIn/config.toml ================================================ [SignIn] enable = true command = ["签到", "每日签到", "qd", "Qd", "QD"] min-point = 3 max-point = 20 streak-cycle = 5 # 每签到?天后,额外积分奖励加1点? max-streak-point = 10 # 额外积分奖励上限 ================================================ FILE: plugins/SignIn/main.py ================================================ import tomllib from datetime import datetime from random import randint import pytz from WechatAPI import WechatAPIClient from database.XYBotDB import XYBotDB from utils.decorators import * from utils.plugin_base import PluginBase class SignIn(PluginBase): description = "每日签到" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() with open("plugins/SignIn/config.toml", "rb") as f: plugin_config = tomllib.load(f) with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) config = plugin_config["SignIn"] main_config = main_config["XYBot"] self.enable = config["enable"] self.command = config["command"] self.min_points = config["min-point"] self.max_points = config["max-point"] self.streak_cycle = config["streak-cycle"] self.max_streak_point = config["max-streak-point"] self.timezone = main_config["timezone"] self.db = XYBotDB() # 每日签到排名数据 self.today_signin_count = 0 self.last_reset_date = datetime.now(tz=pytz.timezone(self.timezone)).date() def _check_and_reset_count(self): current_date = datetime.now(tz=pytz.timezone(self.timezone)).date() if current_date != self.last_reset_date: self.today_signin_count = 0 self.last_reset_date = current_date @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if not len(command) or command[0] not in self.command: return # 检查是否需要重置计数 self._check_and_reset_count() sign_wxid = message["SenderWxid"] last_sign = self.db.get_signin_stat(sign_wxid) now = datetime.now(tz=pytz.timezone(self.timezone)).replace(hour=0, minute=0, second=0, microsecond=0) # 确保 last_sign 用了时区 if last_sign and last_sign.tzinfo is None: last_sign = pytz.timezone(self.timezone).localize(last_sign) last_sign = last_sign.replace(hour=0, minute=0, second=0, microsecond=0) if last_sign and (now - last_sign).days < 1: output = "\n-----XYBot-----\n你今天已经签到过了!😠" await bot.send_at_message(message["FromWxid"], output, [sign_wxid]) return # 检查是否断开连续签到(超过1天没签到) if last_sign and (now - last_sign).days > 1: old_streak = self.db.get_signin_streak(sign_wxid) streak = 1 # 重置连续签到天数 streak_broken = True else: old_streak = self.db.get_signin_streak(sign_wxid) streak = old_streak + 1 if old_streak else 1 # 如果是第一次签到,从1开始 streak_broken = False self.db.set_signin_stat(sign_wxid, now) self.db.set_signin_streak(sign_wxid, streak) # 设置连续签到天数 streak_points = min(streak // self.streak_cycle, self.max_streak_point) # 计算连续签到奖励 signin_points = randint(self.min_points, self.max_points) # 随机积分 self.db.add_points(sign_wxid, signin_points + streak_points) # 增加积分 # 增加签到计数并获取排名 self.today_signin_count += 1 today_rank = self.today_signin_count output = ("\n" f"-----XYBot-----\n" f"签到成功!你领到了 {signin_points} 个积分!✅\n" f"你是今天第 {today_rank} 个签到的!🎉\n") if streak_broken and old_streak > 0: # 只有在真的断签且之前有签到记录时才显示 output += f"你断开了 {old_streak} 天的连续签到![心碎]" elif streak > 1: output += f"你连续签到了 {streak} 天!" if streak_points > 0: output += f" 再奖励 {streak_points} 积分!" if streak > 1 and not streak_broken: output += "[爱心]" await bot.send_at_message(message["FromWxid"], output, [sign_wxid]) ================================================ FILE: plugins/TencentLke/__init__.py ================================================ ================================================ FILE: plugins/TencentLke/config.toml ================================================ [TencentLke] enable = true bot_app_key = "" #腾讯大模型知识引擎 # 其他插件的指令要填进下面,以免冲突 other-plugin-cmd = ["status", "bot", "机器人状态", "状态", "加载插件", "加载所有插件", "卸载插件", "卸载所有插件", "重载插件", "重载所有插件", "插件列表", "插件信息", "随机图片", "随机图图", "查询积分", "积分", "我的积分", "积分查询", "签到", "每日签到", "qd", "Qd", "QD", "重置签到", "重置签到状态", "五子棋", "五子棋创建", "五子棋邀请", "邀请五子棋", "接受", "加入", "下棋", "获取联系人", "联系人", "通讯录", "获取通讯录", "排行榜", "积分榜", "积分排行榜", "群排行榜", "群积分榜", "新闻", "新闻速递", "新闻资讯", "新闻头条", "新闻列表", "随机新闻", "获取新闻", "获取随机新闻", "头条", "头条新闻", "今日头条", "今日新闻", "积分交易", "积分转账", "转账积分", "积分赠送", "赠送积分", "积分转移", "转移积分", "送积分", "积分送人", "送人积分", "积分赠予", "赠予", "战争雷霆", "战雷查询", "战争雷霆玩家", "战雷玩家", "点歌", "音乐", "音乐点播", "点播音乐", "音乐点歌", "抽奖", "幸运抽奖", "抽奖活动", "抽奖指令", "幸运大抽奖", "大抽奖", "积分抽奖", "菜单", "帮助", "帮助菜单", "功能列表", "功能菜单", "指令列表", "指令菜单", "功能", "指令", "cd", "Cd", "cd", "menu", "Menu", "发红包", "抢红包", "加积分", "减积分", "设置积分", "添加白名单", "删除白名单", "白名单列表", "重置签到", "加载插件", "加载所有插件", "卸载插件", "卸载所有插件", "重载插件", "重载所有插件", "插件列表", "随机成员", "随机群员", "随机群成员", "随机群用户"] # Http代理设置 # 格式: http://用户名:密码@代理地址:代理端口 # 例如:http://127.0.0.1:7890 http-proxy = "" ================================================ FILE: plugins/TencentLke/main.py ================================================ import json import random import time import tomllib import aiohttp from WechatAPI import WechatAPIClient from utils.decorators import * from utils.plugin_base import PluginBase class TencentLke(PluginBase): description = "腾讯大模型知识引擎LKE" author = "ChenChongWu" version = "1.0.0" def __init__(self): super().__init__() with open("main_config.toml", "rb") as f: config = tomllib.load(f) self.admins = config["XYBot"]["admins"] with open("plugins/TencentLke/config.toml", "rb") as f: config = tomllib.load(f) plugin_config = config["TencentLke"] self.enable = plugin_config["enable"] self.bot_app_key = plugin_config["bot_app_key"] self.other_plugin_cmd = plugin_config["other-plugin-cmd"] @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return command = str(message["Content"]).strip().split(" ") if command and command[0] in self.other_plugin_cmd: # 指令来自其他插件 return if message["SenderWxid"] in self.admins: # 自己发送不进行AI回答 return content = str(message["Content"]).strip() if (content == ""): return await self.TencentLke(bot, message, message["Content"]) async def TencentLke(self, bot: WechatAPIClient, message: dict, query: str, files=None): headers = {"Content-Type": "application/json"} payload = json.dumps({ 'content': query, 'request_id': str(int(time.time())) + str(random.randint(1, 999)), 'bot_app_key': self.bot_app_key, 'visitor_biz_id': message["FromWxid"], 'session_id': message["FromWxid"] }) url = f"https://wss.lke.cloud.tencent.com/v1/qbot/chat/sse" async with aiohttp.ClientSession(proxy="") as session: async with session.post(url=url, headers=headers, data=payload, timeout=10) as resp: last_line = None async for line in resp.content: # 流式传输 line = line.decode("utf-8").strip() if (line != ""): last_line = line last_line = last_line.strip().replace("data:", "") resp_json = json.loads(last_line) if (resp_json['type'] == "reply"): try: AIResult = resp_json['payload']["content"] if (AIResult != ""): await bot.send_text_message(message.get("FromWxid"), AIResult) except json.JSONDecodeError as e: return return ================================================ FILE: plugins/UpdateQR/__init__.py ================================================ ================================================ FILE: plugins/UpdateQR/main.py ================================================ import imghdr import io import aiohttp from loguru import logger from WechatAPI import WechatAPIClient from utils.decorators import * from utils.plugin_base import PluginBase class UpdateQR(PluginBase): description = "更新群二维码" author = "HenryXiaoYang" version = "1.0.0" def __init__(self): super().__init__() @on_text_message async def on_text(self, bot: WechatAPIClient, message: dict): if message.get("Content") == "更新群二维码": await self.update_qr(bot) @schedule('cron', day_of_week='mon', hour=8) async def monday_update_qr(self, bot: WechatAPIClient): await self.update_qr(bot) @schedule('cron', day_of_week='thu', hour=8) async def thursday_update_qr(self, bot: WechatAPIClient): await self.update_qr(bot) @staticmethod async def update_qr(bot: WechatAPIClient): qr = await bot.get_chatroom_qrcode("56994401945@chatroom") qr = bot.base64_to_byte(qr.get("base64", "")) img_format = imghdr.what(io.BytesIO(qr)) async with aiohttp.ClientSession() as session: headers = {"Authorization": f"Bearer Henry_Yang"} data = aiohttp.FormData() data.add_field('file', qr, filename=f'qr_code.{img_format}', content_type=f'image/{img_format}') async with session.post("https://qrcode.yangres.com/update_image", headers=headers, data=data) as response: if response.status != 200: logger.error("上传失败: HTTP {}", response.status) logger.error(await response.text()) else: logger.success("更新群二维码成功") ================================================ FILE: plugins/Warthunder/__init__.py ================================================ ================================================ FILE: plugins/Warthunder/config.toml ================================================ [Warthunder] enable = true command = ["战争雷霆", "战雷查询", "战争雷霆玩家", "战雷玩家"] command-format = """ -----XYBot----- 🎮战争雷霆玩家查询: 战雷查询 玩家名 """ ================================================ FILE: plugins/Warthunder/main.py ================================================ import asyncio import io import os import tomllib from io import BytesIO import aiohttp import matplotlib.font_manager as fm import matplotlib.pyplot as plt import numpy as np import requests from PIL import Image, ImageDraw, ImageFont from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas from matplotlib.figure import Figure from WechatAPI import WechatAPIClient from utils.decorators import * from utils.plugin_base import PluginBase class Warthunder(PluginBase): description = "战争雷霆玩家查询" author = "HenryXiaoYang" version = "1.1.0" # Change Log # 1.0.0 第一个版本 # 1.1.0 适配新的api格式 def __init__(self): super().__init__() with open("plugins/Warthunder/config.toml", "rb") as f: plugin_config = tomllib.load(f) config = plugin_config["Warthunder"] self.enable = config["enable"] self.command = config["command"] self.command_format = config["command-format"] self.font_path = "resource/font/华文细黑.ttf" @on_text_message async def handle_text(self, bot: WechatAPIClient, message: dict): if not self.enable: return content = str(message["Content"]).strip() command = content.split(" ") if command[0] not in self.command: return if len(command) != 2: await bot.send_text_message(message["FromWxid"], self.command_format) return player_name = content[len(command[0]) + 1:] output = (f"\n-----XYBot-----\n" f"正在查询玩家 {player_name} 的数据,请稍等...😄") a, b, c = await bot.send_at_message(message["FromWxid"], output, [message["SenderWxid"]]) async with aiohttp.ClientSession() as session: async with session.get(f"https://wtapi.yangres.com/player?nick={player_name}") as resp: data = await resp.json() if data["code"] == 404: await bot.send_at_message(message["FromWxid"], f"-----XYBot-----\n🈚️玩家不存在!\n请检查玩家昵称,区分大小写哦!", [message["SenderWxid"]]) await bot.revoke_message(message["FromWxid"], a, b, c) return elif data["code"] == 500: await bot.send_at_message(message["FromWxid"], f"-----XYBot-----\n🙅对不起,API服务出现错误!\n请稍后再试!", [message["SenderWxid"]]) await bot.revoke_message(message["FromWxid"], a, b, c) return elif data["code"] == 400: await bot.send_at_message(message["FromWxid"], f"-----XYBot-----\n🙅对不起,API客户端出现错误!\n请稍后再试!", [message["SenderWxid"]]) await bot.revoke_message(message["FromWxid"], a, b, c) return image = await self.generate_card(data["data"]) await bot.send_image_message(message["FromWxid"], image) await bot.revoke_message(message["FromWxid"], a, b, c) async def generate_card(self, data: dict): loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._generate_card, data) def _generate_card(self, data: dict) -> bytes: width, height = 1800, 2560 top_color = np.array([127, 127, 213]) bottom_color = np.array([145, 234, 228]) # 生成坐标网格 y, x = np.indices((height, width)) # 计算对角线权重(从左上到右下) weight = (x + y) / (width + height) # 向量化计算渐变 gradient = top_color * (1 - weight[..., np.newaxis]) + bottom_color * weight[..., np.newaxis] gradient = gradient.astype(np.uint8) img = Image.fromarray(gradient).convert('RGBA') # 设置矩形参数 margin = 50 # 边距 radius = 30 # 圆角半径 # 绘制半透明圆角矩形 overlay = Image.new("RGBA", img.size, (0, 0, 0, 0)) draw_overlay = ImageDraw.Draw(overlay) draw_overlay.rounded_rectangle( (margin, margin, width - margin, height - margin), radius=radius, fill=(255, 255, 255, 180)) img = Image.alpha_composite(img, overlay) # 开始画数据 fm.fontManager.addfont(self.font_path) plt.rcParams['font.family'] = ['STXihei'] plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题 plt.rcParams['font.size'] = 23 # 使用自定义字体文件 title_font = ImageFont.truetype(self.font_path, size=60) normal_font = ImageFont.truetype(self.font_path, size=45) draw = ImageDraw.Draw(img) # 最上方标题 draw.text((80, 60), "XYBotV2 战争雷霆玩家查询", fill="black", font=title_font) # 头像 avatar = self._download_avatar(data["avatar"]).resize((300, 300)) img.paste(avatar, (80, 160)) # 玩家基础信息 clan_and_nick = f"{data['clan_name']} {data['nickname']}" if data.get('clan_name') else data['nickname'] draw.text((400, 160), clan_and_nick, fill="black", font=normal_font) draw.text((400, 250), f"等级: {data['player_level']}", fill="black", font=normal_font) draw.text((400, 340), f"注册日期: {data['register_date']}", fill="black", font=normal_font) # 载具数据饼图 owned_vehicles = [] country_labels = [] country_translation = {'USA': '美国', 'USSR': '苏联', 'Germany': '德国', 'GreatBritain': '英国', 'Japan': '日本', 'China': '中国', 'Italy': '意大利', 'France': '法国', 'Sweden': '瑞典', 'Israel': '以色列'} countries = dict(data["vehicles_and_rewards"]).keys() for c in countries: vehicles = data["vehicles_and_rewards"][c].get("owned_vehicles", 0) if vehicles > 0: owned_vehicles.append(vehicles) country_labels.append(country_translation.get(c, c)) if owned_vehicles: fig = Figure(figsize=(6, 6), facecolor=(0, 0, 0, 0)) ax = fig.add_subplot(111) color = plt.cm.Pastel1(np.linspace(0, 1, len(owned_vehicles))) # 使用柔和的颜色方案 ax.pie(owned_vehicles, labels=country_labels, autopct=lambda pct: self._show_actual(pct, owned_vehicles), pctdistance=0.5, labeldistance=1.1, colors=color) ax.set_title('载具数据', fontsize=27) canvas = FigureCanvas(fig) buf = BytesIO() canvas.print_png(buf) buf.seek(0) pie_img = Image.open(buf) pie_img = pie_img.resize((650, 650)) img.alpha_composite(pie_img, (1000, 40)) # KDA数据 total_kills = 0 total_deaths = 0 for mode in ['arcade', 'realistic', 'simulation']: stats = data.get('statistics', {}).get(mode, {}) total_kills += stats.get('air_targets_destroyed', 0) total_kills += stats.get('ground_targets_destroyed', 0) total_kills += stats.get('naval_targets_destroyed', 0) total_deaths += stats.get('deaths', 0) kda = round(total_kills / total_deaths if total_deaths > 0 else 0, 2) draw.text((80, 480), f"KDA数据:", fill="black", font=title_font) draw.text((75, 560), f"击杀: {total_kills}", fill="black", font=normal_font) draw.text((350, 560), f"死亡: {total_deaths}", fill="black", font=normal_font) draw.text((600, 560), f"KDA: {kda}", fill="black", font=normal_font) title_font = title_font.font_variant(size=45) normal_font = normal_font.font_variant(size=35) # 数据部分 titles = {"victories": "获胜数", "completed_missions": "完成任务", "victories_battles_ratio": "胜率", "deaths": "死亡数", "lions_earned": "获得银狮", "play_time": "游玩时间", "air_targets_destroyed": "击毁空中目标", "ground_targets_destroyed": "击毁地面目标", "naval_targets_destroyed": "击毁海上目标"} air_titles = {"air_battles": "空战次数", "total_targets_destroyed": "共击毁目标", "air_targets_destroyed": "击毁空中目标", "ground_targets_destroyed": "击毁地面目标", "naval_targets_destroyed": "击毁海上目标", "air_battles_fighters": "战斗机次数", "air_battles_bombers": "轰炸机次数", "air_battles_attackers": "攻击机次数", "time_played_air_battles": "空战时长", "time_played_fighter": "战斗机时长", "time_played_bomber": "轰炸机时长", "time_played_attackers": "攻击机时长"} ground_titles = {"ground_battles": "陆战次数", "total_targets_destroyed": "共击毁目标", "air_targets_destroyed": "击毁空中目标", "ground_targets_destroyed": "击毁地面目标", "naval_targets_destroyed": "击毁海上目标", "ground_battles_tanks": "坦克次数", "ground_battles_spgs": "坦歼次数", "ground_battles_heavy_tanks": "重坦次数", "ground_battles_spaa": "防空车次数", "time_played_ground_battles": "陆战时长", "tank_battle_time": "坦克时长", "tank_destroyer_battle_time": "坦歼时长", "heavy_tank_battle_time": "重坦时长", "spaa_battle_time": "防空车时长"} naval_title = { "naval_battles": "海战次数", "total_targets_destroyed": "共击毁目标", "air_targets_destroyed": "击毁空中目标", "ground_targets_destroyed": "击毁地面目标", "naval_targets_destroyed": "击毁海上目标", "ship_battles": "战舰次数", "motor_torpedo_boat_battles": "鱼雷艇次数", "motor_gun_boat_battles": "炮艇次数", "motor_torpedo_gun_boat_battles": "鱼雷炮艇次数", "sub_chaser_battles": "潜艇次数", "destroyer_battles": "驱逐舰次数", "naval_ferry_barge_battles": "浮船次数", "time_played_naval": "海战时长", "time_played_on_ship": "战舰时长", "time_played_on_motor_torpedo_boat": "鱼雷艇时长", "time_played_on_motor_gun_boat": "炮艇时长", "time_played_on_motor_torpedo_gun_boat": "鱼雷炮艇时长", "time_played_on_sub_chaser": "潜艇时长", "time_played_on_destroyer": "驱逐舰时长", "time_played_on_naval_ferry_barge": "浮船时长" } # 娱乐街机 draw.text((80, 650), f"娱乐街机:", fill="black", font=title_font) y = 710 for key, value in titles.items(): draw.text((80, y), f"{value}: {data['statistics']['arcade'][key]}", fill="black", font=normal_font) y += 37 # 娱乐街机 - 空战 draw.text((400, 650), f"街机-空战:", fill="black", font=title_font) y = 710 for key, value in air_titles.items(): draw.text((400, y), f"{value}: {data['statistics']['arcade']['aviation'][key]}", fill="black", font=normal_font) y += 37 # 娱乐街机 - 陆战 draw.text((750, 650), f"街机-陆战:", fill="black", font=title_font) y = 710 for key, value in ground_titles.items(): draw.text((750, y), f"{value}: {data['statistics']['arcade']['ground'][key]}", fill="black", font=normal_font) y += 37 # 娱乐街机 - 海战 draw.text((1100, 650), f"街机-海战:", fill="black", font=title_font) x, y = 1100, 710 for key, value in naval_title.items(): draw.text((x, y), f"{value}: {data['statistics']['arcade']['fleet'][key]}", fill="black", font=normal_font) y += 37 if y > 1063: x = 1400 y = 710 # 历史性能 draw.text((80, 1250), f"历史性能:", fill="black", font=title_font) y = 1310 for key, value in titles.items(): draw.text((80, y), f"{value}: {data['statistics']['realistic'][key]}", fill="black", font=normal_font) y += 37 # 历史性能 - 空战 draw.text((400, 1250), f"空历:", fill="black", font=title_font) y = 1310 for key, value in air_titles.items(): draw.text((400, y), f"{value}: {data['statistics']['realistic']['aviation'][key]}", fill="black", font=normal_font) y += 37 # 历史性能 - 陆战 draw.text((750, 1250), f"陆历:", fill="black", font=title_font) y = 1310 for key, value in ground_titles.items(): draw.text((750, y), f"{value}: {data['statistics']['realistic']['ground'][key]}", fill="black", font=normal_font) y += 37 # 历史性能 - 海战 draw.text((1100, 1250), f"历史性能-海战:", fill="black", font=title_font) x, y = 1100, 1310 for key, value in naval_title.items(): draw.text((x, y), f"{value}: {data['statistics']['realistic']['fleet'][key]}", fill="black", font=normal_font) y += 37 if y > 1663: x = 1400 y = 1310 # 真实模拟 draw.text((80, 1850), f"真实模拟:", fill="black", font=title_font) y = 1910 for key, value in titles.items(): draw.text((80, y), f"{value}: {data['statistics']['simulation'][key]}", fill="black", font=normal_font) y += 37 # 真实模拟 - 空战 draw.text((400, 1850), f"真实模拟-空战:", fill="black", font=title_font) y = 1910 for key, value in air_titles.items(): draw.text((400, y), f"{value}: {data['statistics']['realistic']['aviation'][key]}", fill="black", font=normal_font) y += 37 # 真实模拟 - 陆战 draw.text((750, 1850), f"真实模拟-陆战:", fill="black", font=title_font) y = 1910 for key, value in ground_titles.items(): draw.text((750, y), f"{value}: {data['statistics']['realistic']['ground'][key]}", fill="black", font=normal_font) y += 37 # 真实模拟 - 海战 draw.text((1100, 1850), f"真实模拟-海战:", fill="black", font=title_font) x, y = 1100, 1910 for key, value in naval_title.items(): draw.text((x, y), f"{value}: {data['statistics']['realistic']['fleet'][key]}", fill="black", font=normal_font) y += 37 if y > 2263: x = 1400 y = 1910 byte_array = io.BytesIO() img.save(byte_array, "PNG") return byte_array.getvalue() @staticmethod def _download_avatar(url: str) -> Image.Image: try: # 创建缓存目录 cache_dir = "resource/images/avatar" os.makedirs(cache_dir, exist_ok=True) # 使用URL的最后部分作为文件名 file_path = os.path.join(cache_dir, url.split('/')[-1]) # 检查缓存 if os.path.exists(file_path): return Image.open(file_path) else: resp = requests.get(url) with open(file_path, "wb") as f: f.write(resp.content) return Image.open(file_path) except: return Image.new("RGBA", (150, 150), (255, 255, 255, 255)) @staticmethod def _show_actual(pct, allvals): absolute = int(np.round(pct / 100. * sum(allvals))) # 将百分比转换为实际值[3][9] return f"{absolute}" ================================================ FILE: redis.conf ================================================ dir /var/lib/redis save 60 1000 appendonly yes appendfsync everysec ================================================ FILE: requirements.txt ================================================ loguru~=0.7.3 APScheduler~=3.11.0 aiohttp~=3.11.14 filetype~=1.2.0 pillow==10.4.0 pytz~=2025.1 captcha~=0.7.1 tabulate~=0.9.0 jieba~=0.42.1 SQLAlchemy~=2.0.39 pydub~=0.25.1 qrcode~=8.0 moviepy~=2.1.2 pysilk-mod~=1.6.4 xywechatpad-binary==1.1.0 pymediainfo~=7.0.1 matplotlib~=3.10.1 numpy~=1.26.4 requests~=2.32.3 pydantic~=2.10.6 aiosqlite~=0.21.0 Flask~=2.3.3 Flask-Login~=0.6.3 Flask-WTF~=1.2.1 Flask-Session~=0.8.0 Flask-SocketIO~=5.5.1 WTForms~=3.2.1 Werkzeug~=3.1.3 Bootstrap-Flask~=2.3.3 python-dotenv~=1.0.1 tomli~=2.2.1 tomlkit~=0.13.2 eventlet~=0.39.1 ================================================ FILE: utils/decorators.py ================================================ from functools import wraps from typing import Callable, Union from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.interval import IntervalTrigger scheduler = AsyncIOScheduler() def schedule( trigger: Union[str, CronTrigger, IntervalTrigger], **trigger_args ) -> Callable: """ 定时任务装饰器 例子: - @schedule('interval', seconds=30) - @schedule('cron', hour=8, minute=30, second=30) - @schedule('date', run_date='2024-01-01 00:00:00') """ def decorator(func: Callable): job_id = f"{func.__module__}.{func.__qualname__}" @wraps(func) async def wrapper(self, *args, **kwargs): return await func(self, *args, **kwargs) setattr(wrapper, '_is_scheduled', True) setattr(wrapper, '_schedule_trigger', trigger) setattr(wrapper, '_schedule_args', trigger_args) setattr(wrapper, '_job_id', job_id) return wrapper return decorator def add_job_safe(scheduler: AsyncIOScheduler, job_id: str, func: Callable, bot, trigger: Union[str, CronTrigger, IntervalTrigger], **trigger_args): """添加函数到定时任务中,如果存在则先删除现有的任务""" try: scheduler.remove_job(job_id) except: pass scheduler.add_job(func, trigger, args=[bot], id=job_id, **trigger_args) def remove_job_safe(scheduler: AsyncIOScheduler, job_id: str): """从定时任务中移除任务""" try: scheduler.remove_job(job_id) except: pass def on_text_message(priority=50): """文本消息装饰器""" def decorator(func): if callable(priority): # 无参数调用时 f = priority setattr(f, '_event_type', 'text_message') setattr(f, '_priority', 50) return f # 有参数调用时 setattr(func, '_event_type', 'text_message') setattr(func, '_priority', min(max(priority, 0), 99)) return func return decorator if not callable(priority) else decorator(priority) def on_image_message(priority=50): """图片消息装饰器""" def decorator(func): if callable(priority): f = priority setattr(f, '_event_type', 'image_message') setattr(f, '_priority', 50) return f setattr(func, '_event_type', 'image_message') setattr(func, '_priority', min(max(priority, 0), 99)) return func return decorator if not callable(priority) else decorator(priority) def on_voice_message(priority=50): """语音消息装饰器""" def decorator(func): if callable(priority): f = priority setattr(f, '_event_type', 'voice_message') setattr(f, '_priority', 50) return f setattr(func, '_event_type', 'voice_message') setattr(func, '_priority', min(max(priority, 0), 99)) return func return decorator if not callable(priority) else decorator(priority) def on_emoji_message(priority=50): """表情消息装饰器""" def decorator(func): if callable(priority): f = priority setattr(f, '_event_type', 'emoji_message') setattr(f, '_priority', 50) return f setattr(func, '_event_type', 'emoji_message') setattr(func, '_priority', min(max(priority, 0), 99)) return func return decorator if not callable(priority) else decorator(priority) def on_file_message(priority=50): """文件消息装饰器""" def decorator(func): if callable(priority): f = priority setattr(f, '_event_type', 'file_message') setattr(f, '_priority', 50) return f setattr(func, '_event_type', 'file_message') setattr(func, '_priority', min(max(priority, 0), 99)) return func return decorator if not callable(priority) else decorator(priority) def on_quote_message(priority=50): """引用消息装饰器""" def decorator(func): if callable(priority): f = priority setattr(f, '_event_type', 'quote_message') setattr(f, '_priority', 50) return f setattr(func, '_event_type', 'quote_message') setattr(func, '_priority', min(max(priority, 0), 99)) return func return decorator if not callable(priority) else decorator(priority) def on_video_message(priority=50): """视频消息装饰器""" def decorator(func): if callable(priority): f = priority setattr(f, '_event_type', 'video_message') setattr(f, '_priority', 50) return f setattr(func, '_event_type', 'video_message') setattr(func, '_priority', min(max(priority, 0), 99)) return func return decorator if not callable(priority) else decorator(priority) def on_pat_message(priority=50): """拍一拍消息装饰器""" def decorator(func): if callable(priority): f = priority setattr(f, '_event_type', 'pat_message') setattr(f, '_priority', 50) return f setattr(func, '_event_type', 'pat_message') setattr(func, '_priority', min(max(priority, 0), 99)) return func return decorator if not callable(priority) else decorator(priority) def on_at_message(priority=50): """被@消息装饰器""" def decorator(func): if callable(priority): f = priority setattr(f, '_event_type', 'at_message') setattr(f, '_priority', 50) return f setattr(func, '_event_type', 'at_message') setattr(func, '_priority', min(max(priority, 0), 99)) return func return decorator if not callable(priority) else decorator(priority) def on_system_message(priority=50): """其他消息装饰器""" def decorator(func): if callable(priority): f = priority setattr(f, '_event_type', 'system_message') setattr(f, '_priority', 50) return f setattr(func, '_event_type', 'other_message') setattr(func, '_priority', min(max(priority, 0), 99)) return func return decorator if not callable(priority) else decorator(priority) def on_other_message(priority=50): """其他消息装饰器""" def decorator(func): if callable(priority): f = priority setattr(f, '_event_type', 'other_message') setattr(f, '_priority', 50) return f setattr(func, '_event_type', 'other_message') setattr(func, '_priority', min(max(priority, 0), 99)) return func return decorator if not callable(priority) else decorator(priority) ================================================ FILE: utils/event_manager.py ================================================ import copy from typing import Callable, Dict, List class EventManager: _handlers: Dict[str, List[tuple[Callable, object, int]]] = {} @classmethod def bind_instance(cls, instance: object): """将实例绑定到对应的事件处理函数""" for method_name in dir(instance): method = getattr(instance, method_name) if hasattr(method, '_event_type'): event_type = getattr(method, '_event_type') priority = getattr(method, '_priority', 50) if event_type not in cls._handlers: cls._handlers[event_type] = [] cls._handlers[event_type].append((method, instance, priority)) # 按优先级排序,优先级高的在前 cls._handlers[event_type].sort(key=lambda x: x[2], reverse=True) @classmethod async def emit(cls, event_type: str, *args, **kwargs) -> None: """触发事件""" if event_type not in cls._handlers: return api_client, message = args for handler, instance, priority in cls._handlers[event_type]: # 只对 message 进行深拷贝,api_client 保持不变 handler_args = (api_client, copy.deepcopy(message)) new_kwargs = {k: copy.deepcopy(v) for k, v in kwargs.items()} result = await handler(*handler_args, **new_kwargs) if isinstance(result, bool): # True 继续执行 False 停止执行 if not result: break else: continue # 我也不知道你返回了个啥玩意,反正继续执行就是了 @classmethod def unbind_instance(cls, instance: object): """解绑实例的所有事件处理函数""" for event_type in cls._handlers: cls._handlers[event_type] = [ (handler, inst, priority) for handler, inst, priority in cls._handlers[event_type] if inst is not instance ] ================================================ FILE: utils/plugin_base.py ================================================ from abc import ABC from loguru import logger from .decorators import scheduler, add_job_safe, remove_job_safe class PluginBase(ABC): """插件基类""" # 插件元数据 description: str = "暂无描述" author: str = "未知" version: str = "1.0.0" def __init__(self): self.enabled = False self._scheduled_jobs = set() async def on_enable(self, bot=None): """插件启用时调用""" # 定时任务 for method_name in dir(self): method = getattr(self, method_name) if hasattr(method, '_is_scheduled'): job_id = getattr(method, '_job_id') trigger = getattr(method, '_schedule_trigger') trigger_args = getattr(method, '_schedule_args') add_job_safe(scheduler, job_id, method, bot, trigger, **trigger_args) self._scheduled_jobs.add(job_id) if self._scheduled_jobs: logger.success("插件 {} 已加载定时任务: {}", self.__class__.__name__, self._scheduled_jobs) async def on_disable(self): """插件禁用时调用""" # 移除定时任务 for job_id in self._scheduled_jobs: remove_job_safe(scheduler, job_id) logger.info("已卸载定时任务: {}", self._scheduled_jobs) self._scheduled_jobs.clear() async def async_init(self): """插件异步初始化""" return ================================================ FILE: utils/plugin_manager.py ================================================ import importlib import inspect import os import sys import tomllib import traceback from typing import Dict, Type, List, Union from loguru import logger from WechatAPI import WechatAPIClient from utils.singleton import Singleton from .event_manager import EventManager from .plugin_base import PluginBase class PluginManager(metaclass=Singleton): def __init__(self): self.plugins: Dict[str, PluginBase] = {} self.plugin_classes: Dict[str, Type[PluginBase]] = {} self.plugin_info: Dict[str, dict] = {} # 新增:存储所有插件信息 self.bot = None with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) self.excluded_plugins = main_config["XYBot"]["disabled-plugins"] def set_bot(self, bot: WechatAPIClient): self.bot = bot async def load_plugin(self, plugin: Union[Type[PluginBase], str]) -> bool: if isinstance(plugin, str): return await self._load_plugin_name(plugin) elif isinstance(plugin, type) and issubclass(plugin, PluginBase): return await self._load_plugin_class(plugin) async def _load_plugin_class(self, plugin_class: Type[PluginBase], is_disabled: bool = False) -> bool: """加载单个插件,接受Type[PluginBase]""" try: plugin_name = plugin_class.__name__ # 防止重复加载插件 if plugin_name in self.plugins: return False # 安全获取插件目录名 directory = "unknown" try: module_name = plugin_class.__module__ if module_name.startswith("plugins."): directory = module_name.split('.')[1] else: logger.warning(f"非常规插件模块路径: {module_name}") except Exception as e: logger.error(f"获取插件目录失败: {e}") directory = "error" # 记录插件信息,即使插件被禁用也会记录 self.plugin_info[plugin_name] = { "name": plugin_name, "description": plugin_class.description, "author": plugin_class.author, "version": plugin_class.version, "directory": directory, "enabled": False, "class": plugin_class } # 如果插件被禁用则不加载 if is_disabled: return False plugin = plugin_class() EventManager.bind_instance(plugin) await plugin.on_enable(self.bot) await plugin.async_init() self.plugins[plugin_name] = plugin self.plugin_classes[plugin_name] = plugin_class self.plugin_info[plugin_name]["enabled"] = True logger.success(f"加载插件 {plugin_name} 成功") return True except: logger.error(f"加载插件时发生错误: {traceback.format_exc()}") return False async def _load_plugin_name(self, plugin_name: str) -> bool: """从plugins目录加载单个插件 Args: plugin_name: 插件类名称(不是文件名) Returns: bool: 是否成功加载插件 """ found = False for dirname in os.listdir("plugins"): try: if os.path.isdir(f"plugins/{dirname}") and os.path.exists(f"plugins/{dirname}/main.py"): module = importlib.import_module(f"plugins.{dirname}.main") importlib.reload(module) for name, obj in inspect.getmembers(module): if (inspect.isclass(obj) and issubclass(obj, PluginBase) and obj != PluginBase and obj.__name__ == plugin_name): found = True return await self._load_plugin_class(obj) except: logger.error(f"检查 {dirname} 时发生错误: {traceback.format_exc()}") continue if not found: logger.warning(f"未找到插件类 {plugin_name}") async def load_plugins(self, load_disabled: bool = True) -> Union[List[str], bool]: loaded_plugins = [] for dirname in os.listdir("plugins"): if os.path.isdir(f"plugins/{dirname}") and os.path.exists(f"plugins/{dirname}/main.py"): try: module = importlib.import_module(f"plugins.{dirname}.main") for name, obj in inspect.getmembers(module): if inspect.isclass(obj) and issubclass(obj, PluginBase) and obj != PluginBase: is_disabled = False if not load_disabled: is_disabled = obj.__name__ in self.excluded_plugins or dirname in self.excluded_plugins if await self._load_plugin_class(obj, is_disabled=is_disabled): loaded_plugins.append(obj.__name__) except: logger.error(f"加载 {dirname} 时发生错误: {traceback.format_exc()}") return loaded_plugins async def unload_plugin(self, plugin_name: str) -> bool: """卸载单个插件""" if plugin_name not in self.plugins: return False # 防止卸载 ManagePlugin if plugin_name == "ManagePlugin": logger.warning("ManagePlugin 不能被卸载") return False try: plugin = self.plugins[plugin_name] await plugin.on_disable() EventManager.unbind_instance(plugin) del self.plugins[plugin_name] del self.plugin_classes[plugin_name] if plugin_name in self.plugin_info.keys(): self.plugin_info[plugin_name]["enabled"] = False logger.success(f"卸载插件 {plugin_name} 成功") return True except: logger.error(f"卸载插件 {plugin_name} 时发生错误: {traceback.format_exc()}") return False async def unload_plugins(self) -> tuple[List[str], List[str]]: """卸载所有插件""" unloaded_plugins = [] failed_unloads = [] for plugin_name in list(self.plugins.keys()): if await self.unload_plugin(plugin_name): unloaded_plugins.append(plugin_name) else: failed_unloads.append(plugin_name) return unloaded_plugins, failed_unloads async def reload_plugin(self, plugin_name: str) -> bool: """重载单个插件""" if plugin_name not in self.plugin_classes: return False # 防止重载 ManagePlugin if plugin_name == "ManagePlugin": logger.warning("ManagePlugin 不能被重载") return False try: # 获取插件类所在的模块 plugin_class = self.plugin_classes[plugin_name] module_name = plugin_class.__module__ # 先卸载插件 if not await self.unload_plugin(plugin_name): return False # 重新导入模块 module = importlib.import_module(module_name) importlib.reload(module) # 从重新加载的模块中获取插件类 for name, obj in inspect.getmembers(module): if (inspect.isclass(obj) and issubclass(obj, PluginBase) and obj != PluginBase and obj.__name__ == plugin_name): # 使用新的插件类而不是旧的 return await self.load_plugin(obj) return False except Exception as e: logger.error(f"重载插件 {plugin_name} 时发生错误: {e}") return False async def reload_plugins(self) -> List[str]: """重载所有插件 Returns: List[str]: 成功重载的插件名称列表 """ try: # 记录当前加载的插件名称,排除 ManagePlugin original_plugins = [name for name in self.plugins.keys() if name != "ManagePlugin"] # 卸载除 ManagePlugin 外的所有插件 for plugin_name in original_plugins: await self.unload_plugin(plugin_name) # 重新加载所有模块 for module_name in list(sys.modules.keys()): if module_name.startswith('plugins.') and not module_name.endswith('ManagePlugin'): del sys.modules[module_name] # 从目录重新加载插件 return await self.load_plugins() except: logger.error(f"重载所有插件时发生错误: {traceback.format_exc()}") return [] async def refresh_plugins(self): for dirname in os.listdir("plugins"): try: dirpath = f"plugins/{dirname}" if os.path.isdir(dirpath) and os.path.exists(f"{dirpath}/main.py"): # 验证目录名合法性 if not dirname.isidentifier(): logger.warning(f"跳过非法插件目录名: {dirname}") continue module = importlib.import_module(f"plugins.{dirname}.main") importlib.reload(module) for name, obj in inspect.getmembers(module): if inspect.isclass(obj) and issubclass(obj, PluginBase) and obj != PluginBase: if obj.__name__ not in self.plugin_info.keys(): self.plugin_info[obj.__name__] = { "name": obj.__name__, "description": obj.description, "author": obj.author, "version": obj.version, "directory": dirname, "enabled": False, "class": obj } except: logger.error(f"检查 {dirname} 时发生错误: {traceback.format_exc()}") continue def get_plugin_info(self, plugin_name: str = None) -> Union[dict, List[dict]]: """获取插件信息 Args: plugin_name: 插件名称,如果为None则返回所有插件信息 Returns: 如果指定插件名,返回单个插件信息字典;否则返回所有插件信息列表 """ if plugin_name: return self.plugin_info.get(plugin_name) return list(self.plugin_info.values()) ================================================ FILE: utils/singleton.py ================================================ class Singleton(type): _instances = {} def __call__(cls, *args, **kwargs): if cls not in cls._instances: cls._instances[cls] = super().__call__(*args, **kwargs) return cls._instances[cls] @classmethod def reset_instance(mcs, cls): """重置指定类的单例实例""" if cls in mcs._instances: del mcs._instances[cls] @classmethod def reset_all(mcs): """重置所有单例实例""" mcs._instances.clear() ================================================ FILE: utils/xybot.py ================================================ import tomllib import xml.etree.ElementTree as ET from typing import Dict, Any from loguru import logger from WechatAPI import WechatAPIClient from WechatAPI.Client.protect import protector from database.keyvalDB import KeyvalDB from database.messsagDB import MessageDB from utils.event_manager import EventManager class XYBot: def __init__(self, bot_client: WechatAPIClient): self.bot = bot_client self.wxid = None self.nickname = None self.alias = None self.phone = None with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) self.ignore_protection = main_config.get("XYBot", {}).get("ignore-protection", False) self.ignore_mode = main_config.get("XYBot", {}).get("ignore-mode", "") self.whitelist = main_config.get("XYBot", {}).get("whitelist", []) self.blacklist = main_config.get("XYBot", {}).get("blacklist", []) self.msg_db = MessageDB() self.key_db = KeyvalDB() def update_profile(self, wxid: str, nickname: str, alias: str, phone: str): """更新机器人信息""" self.wxid = wxid self.nickname = nickname self.alias = alias self.phone = phone async def process_message(self, message: Dict[str, Any]): """处理接收到的消息""" # 数据库消息数+1先 msg_count = int(await self.key_db.get("messages") or 0) + 1 await self.key_db.set("messages", str(msg_count)) # 同时更新WebUI使用的消息计数键 await self.key_db.set("bot:stats:message_count", str(msg_count)) msg_type = message.get("MsgType") # 预处理消息 message["FromWxid"] = message.get("FromUserName").get("string") message.pop("FromUserName") message["ToWxid"] = message.get("ToWxid").get("string") # 处理一下自己发的消息 if message["FromWxid"] == self.wxid and message["ToWxid"].endswith("@chatroom"): # 自己发发到群聊 # 由于是自己发送的消息,所以对于自己来说,From和To是反的 message["FromWxid"], message["ToWxid"] = message["ToWxid"], message["FromWxid"] # 根据消息类型触发不同的事件 if msg_type == 1: # 文本消息 await self.process_text_message(message) elif msg_type == 3: # 图片消息 await self.process_image_message(message) elif msg_type == 34: # 语音消息 await self.process_voice_message(message) elif msg_type == 43: # 视频消息 await self.process_video_message(message) elif msg_type == 49: # xml消息 await self.process_xml_message(message) elif msg_type == 10002: # 系统消息 await self.process_system_message(message) elif msg_type == 37: # 好友请求 if self.ignore_protection or not protector.check(14400): await EventManager.emit("friend_request", self.bot, message) else: logger.warning("风控保护: 新设备登录后4小时内请挂机") elif msg_type == 51: pass else: logger.info("未知的消息类型: {}", message) # 可以继续添加更多消息类型的处理 async def process_text_message(self, message: Dict[str, Any]): """处理文本消息""" # 预处理消息 message["Content"] = message.get("Content").get("string") if message["FromWxid"].endswith("@chatroom"): # 群聊消息 message["IsGroup"] = True split_content = message["Content"].split(":\n", 1) if len(split_content) > 1: message["Content"] = split_content[1] message["SenderWxid"] = split_content[0] else: # 绝对是自己发的消息! qwq message["Content"] = split_content[0] message["SenderWxid"] = self.wxid else: message["SenderWxid"] = message["FromWxid"] if message["FromWxid"] == self.wxid: # 自己发的消息 message["FromWxid"] = message["ToWxid"] message["IsGroup"] = False try: root = ET.fromstring(message["MsgSource"]) ats = root.find("atuserlist").text if root.find("atuserlist") is not None else "" except Exception as e: logger.error("解析文本消息失败: {}", e) return if ats: ats = ats.strip(",").split(",") else: # 修复 ats = [] message["Ats"] = ats if ats and ats[0] != "" else [] # 保存消息到数据库 await self.msg_db.save_message( msg_id=int(message["MsgId"]), sender_wxid=message["SenderWxid"], from_wxid=message["FromWxid"], msg_type=int(message["MsgType"]), content=message["Content"], is_group=message["IsGroup"] ) if self.wxid in ats: logger.info("收到被@消息: 消息ID:{} 来自:{} 发送人:{} @:{} 内容:{}", message["MsgId"], message["FromWxid"], message["SenderWxid"], message["Ats"], message["Content"]) if self.ignore_check(message["FromWxid"], message["SenderWxid"]): if self.ignore_protection or not protector.check(14400): await EventManager.emit("at_message", self.bot, message) else: logger.warning("风控保护: 新设备登录后4小时内请挂机") return logger.info("收到文本消息: 消息ID:{} 来自:{} 发送人:{} @:{} 内容:{}", message["MsgId"], message["FromWxid"], message["SenderWxid"], message["Ats"], message["Content"]) if self.ignore_check(message["FromWxid"], message["SenderWxid"]): if self.ignore_protection or not protector.check(14400): await EventManager.emit("text_message", self.bot, message) else: logger.warning("风控保护: 新设备登录后4小时内请挂机") async def process_image_message(self, message: Dict[str, Any]): """处理图片消息""" # 预处理消息 message["Content"] = message.get("Content").get("string").replace("\n", "").replace("\t", "") if message["FromWxid"].endswith("@chatroom"): # 群聊消息 message["IsGroup"] = True split_content = message["Content"].split(":", 1) if len(split_content) > 1: message["Content"] = split_content[1] message["SenderWxid"] = split_content[0] else: # 绝对是自己发的消息! qwq message["Content"] = split_content[0] message["SenderWxid"] = self.wxid else: message["SenderWxid"] = message["FromWxid"] if message["FromWxid"] == self.wxid: # 自己发的消息 message["FromWxid"] = message["ToWxid"] message["IsGroup"] = False logger.info("收到图片消息: 消息ID:{} 来自:{} 发送人:{} XML:{}", message["MsgId"], message["FromWxid"], message["SenderWxid"], message["Content"]) await self.msg_db.save_message( msg_id=int(message["MsgId"]), sender_wxid=message["SenderWxid"], from_wxid=message["FromWxid"], msg_type=int(message["MsgType"]), content=message["MsgSource"], is_group=message["IsGroup"] ) # 解析图片消息 aeskey, cdnmidimgurl = None, None try: root = ET.fromstring(message["Content"]) img_element = root.find('img') if img_element is not None: aeskey = img_element.get('aeskey') cdnmidimgurl = img_element.get('cdnmidimgurl') except Exception as e: logger.error("解析图片消息失败: {}", e) return # 下载图片 if aeskey and cdnmidimgurl: message["Content"] = await self.bot.download_image(aeskey, cdnmidimgurl) if self.ignore_check(message["FromWxid"], message["SenderWxid"]): if self.ignore_protection or not protector.check(14400): await EventManager.emit("image_message", self.bot, message) else: logger.warning("风控保护: 新设备登录后4小时内请挂机") async def process_voice_message(self, message: Dict[str, Any]): """处理语音消息""" # 预处理消息 message["Content"] = message.get("Content").get("string").replace("\n", "").replace("\t", "") if message["FromWxid"].endswith("@chatroom"): # 群聊消息 message["IsGroup"] = True split_content = message["Content"].split(":", 1) if len(split_content) > 1: message["Content"] = split_content[1] message["SenderWxid"] = split_content[0] else: # 绝对是自己发的消息! qwq message["Content"] = split_content[0] message["SenderWxid"] = self.wxid else: message["SenderWxid"] = message["FromWxid"] if message["FromWxid"] == self.wxid: # 自己发的消息 message["FromWxid"] = message["ToWxid"] message["IsGroup"] = False logger.info("收到语音消息: 消息ID:{} 来自:{} 发送人:{} XML:{}", message["MsgId"], message["FromWxid"], message["SenderWxid"], message["Content"]) await self.msg_db.save_message( msg_id=int(message["MsgId"]), sender_wxid=message["SenderWxid"], from_wxid=message["FromWxid"], msg_type=int(message["MsgType"]), content=message["Content"], is_group=message["IsGroup"] ) if message["IsGroup"] or not message.get("ImgBuf", {}).get("buffer", ""): # 解析语音消息 voiceurl, length = None, None try: root = ET.fromstring(message["Content"]) voicemsg_element = root.find('voicemsg') if voicemsg_element is not None: voiceurl = voicemsg_element.get('voiceurl') length = int(voicemsg_element.get('length')) except Exception as e: logger.error("解析语音消息失败: {}", e) return # 下载语音 if voiceurl and length: silk_base64 = await self.bot.download_voice(message["MsgId"], voiceurl, length) message["Content"] = await self.bot.silk_base64_to_wav_byte(silk_base64) else: silk_base64 = message["ImgBuf"]["buffer"] message["Content"] = await self.bot.silk_base64_to_wav_byte(silk_base64) if self.ignore_check(message["FromWxid"], message["SenderWxid"]): if self.ignore_protection or not protector.check(14400): await EventManager.emit("voice_message", self.bot, message) else: logger.warning("风控保护: 新设备登录后4小时内请挂机") async def process_xml_message(self, message: Dict[str, Any]): """处理xml消息""" message["Content"] = message.get("Content").get("string").replace("\n", "").replace("\t", "") if message["FromWxid"].endswith("@chatroom"): # 群聊消息 message["IsGroup"] = True split_content = message["Content"].split(":", 1) if len(split_content) > 1: message["Content"] = split_content[1] message["SenderWxid"] = split_content[0] else: # 绝对是自己发的消息! qwq message["Content"] = split_content[0] message["SenderWxid"] = self.wxid else: message["SenderWxid"] = message["FromWxid"] if message["FromWxid"] == self.wxid: # 自己发的消息 message["FromWxid"] = message["ToWxid"] message["IsGroup"] = False await self.msg_db.save_message( msg_id=int(message["MsgId"]), sender_wxid=message["SenderWxid"], from_wxid=message["FromWxid"], msg_type=int(message["MsgType"]), content=message["Content"], is_group=message["IsGroup"] ) try: root = ET.fromstring(message["Content"]) type = int(root.find("appmsg").find("type").text) except Exception as e: logger.error(f"解析xml消息失败: {e}") return if type == 57: await self.process_quote_message(message) elif type == 6: await self.process_file_message(message) elif type == 74: # 文件消息,但还在上传,不用管 pass else: logger.info("未知的xml消息类型: {}", message) async def process_quote_message(self, message: Dict[str, Any]): """处理引用消息""" quote_messsage = {} try: root = ET.fromstring(message["Content"]) appmsg = root.find("appmsg") text = appmsg.find("title").text refermsg = appmsg.find("refermsg") quote_messsage["MsgType"] = int(refermsg.find("type").text) if quote_messsage["MsgType"] == 1: # 文本消息 quote_messsage["NewMsgId"] = refermsg.find("svrid").text quote_messsage["ToWxid"] = refermsg.find("fromusr").text quote_messsage["FromWxid"] = refermsg.find("chatusr").text quote_messsage["Nickname"] = refermsg.find("displayname").text quote_messsage["MsgSource"] = refermsg.find("msgsource").text quote_messsage["Content"] = refermsg.find("content").text quote_messsage["Createtime"] = refermsg.find("createtime").text elif quote_messsage["MsgType"] == 49: # 引用消息 quote_messsage["NewMsgId"] = refermsg.find("svrid").text quote_messsage["ToWxid"] = refermsg.find("fromusr").text quote_messsage["FromWxid"] = refermsg.find("chatusr").text quote_messsage["Nickname"] = refermsg.find("displayname").text quote_messsage["MsgSource"] = refermsg.find("msgsource").text quote_messsage["Createtime"] = refermsg.find("createtime").text quote_messsage["Content"] = refermsg.find("content").text quote_root = ET.fromstring(quote_messsage["Content"]) quote_appmsg = quote_root.find("appmsg") quote_messsage["Content"] = quote_appmsg.find("title").text if isinstance(quote_appmsg.find("title"), ET.Element) else "" quote_messsage["destination"] = quote_appmsg.find("des").text if isinstance(quote_appmsg.find("des"), ET.Element) else "" quote_messsage["action"] = quote_appmsg.find("action").text if isinstance(quote_appmsg.find("action"), ET.Element) else "" quote_messsage["XmlType"] = int(quote_appmsg.find("type").text) if isinstance(quote_appmsg.find("type"), ET.Element) else 0 quote_messsage["showtype"] = int(quote_appmsg.find("showtype").text) if isinstance( quote_appmsg.find("showtype"), ET.Element) else 0 quote_messsage["soundtype"] = int(quote_appmsg.find("soundtype").text) if isinstance( quote_appmsg.find("soundtype"), ET.Element) else 0 quote_messsage["url"] = quote_appmsg.find("url").text if isinstance(quote_appmsg.find("url"), ET.Element) else "" quote_messsage["lowurl"] = quote_appmsg.find("lowurl").text if isinstance(quote_appmsg.find("lowurl"), ET.Element) else "" quote_messsage["dataurl"] = quote_appmsg.find("dataurl").text if isinstance( quote_appmsg.find("dataurl"), ET.Element) else "" quote_messsage["lowdataurl"] = quote_appmsg.find("lowdataurl").text if isinstance( quote_appmsg.find("lowdataurl"), ET.Element) else "" quote_messsage["songlyric"] = quote_appmsg.find("songlyric").text if isinstance( quote_appmsg.find("songlyric"), ET.Element) else "" quote_messsage["appattach"] = {} quote_messsage["appattach"]["totallen"] = int( quote_appmsg.find("appattach").find("totallen").text) if isinstance( quote_appmsg.find("appattach").find("totallen"), ET.Element) else 0 quote_messsage["appattach"]["attachid"] = quote_appmsg.find("appattach").find( "attachid").text if isinstance(quote_appmsg.find("appattach").find("attachid"), ET.Element) else "" quote_messsage["appattach"]["emoticonmd5"] = quote_appmsg.find("appattach").find( "emoticonmd5").text if isinstance(quote_appmsg.find("appattach").find("emoticonmd5"), ET.Element) else "" quote_messsage["appattach"]["fileext"] = quote_appmsg.find("appattach").find( "fileext").text if isinstance(quote_appmsg.find("appattach").find("fileext"), ET.Element) else "" quote_messsage["appattach"]["cdnthumbaeskey"] = quote_appmsg.find("appattach").find( "cdnthumbaeskey").text if isinstance(quote_appmsg.find("appattach").find("cdnthumbaeskey"), ET.Element) else "" quote_messsage["appattach"]["aeskey"] = quote_appmsg.find("appattach").find( "aeskey").text if isinstance(quote_appmsg.find("appattach").find("aeskey"), ET.Element) else "" quote_messsage["extinfo"] = quote_appmsg.find("extinfo").text if isinstance( quote_appmsg.find("extinfo"), ET.Element) else "" quote_messsage["sourceusername"] = quote_appmsg.find("sourceusername").text if isinstance( quote_appmsg.find("sourceusername"), ET.Element) else "" quote_messsage["sourcedisplayname"] = quote_appmsg.find("sourcedisplayname").text if isinstance( quote_appmsg.find("sourcedisplayname"), ET.Element) else "" quote_messsage["thumburl"] = quote_appmsg.find("thumburl").text if isinstance( quote_appmsg.find("thumburl"), ET.Element) else "" quote_messsage["md5"] = quote_appmsg.find("md5").text if isinstance(quote_appmsg.find("md5"), ET.Element) else "" quote_messsage["statextstr"] = quote_appmsg.find("statextstr").text if isinstance( quote_appmsg.find("statextstr"), ET.Element) else "" quote_messsage["directshare"] = int(quote_appmsg.find("directshare").text) if isinstance( quote_appmsg.find("directshare"), ET.Element) else 0 except Exception as e: logger.error(f"解析引用消息失败: {e}") return message["Content"] = text message["Quote"] = quote_messsage logger.info("收到引用消息: 消息ID:{} 来自:{} 发送人:{} 内容:{} 引用:{}", message.get("Msgid", ""), message["FromWxid"], message["SenderWxid"], message["Content"], message["Quote"]) if self.ignore_check(message["FromWxid"], message["SenderWxid"]): if self.ignore_protection or not protector.check(14400): await EventManager.emit("quote_message", self.bot, message) else: logger.warning("风控保护: 新设备登录后4小时内请挂机") async def process_video_message(self, message): # 预处理消息 message["Content"] = message.get("Content").get("string") if message["FromWxid"].endswith("@chatroom"): # 群聊消息 message["IsGroup"] = True split_content = message["Content"].split(":", 1) if len(split_content) > 1: message["Content"] = split_content[1] message["SenderWxid"] = split_content[0] else: # 绝对是自己发的消息! qwq message["Content"] = split_content[0] message["SenderWxid"] = self.wxid else: message["SenderWxid"] = message["FromWxid"] if message["FromWxid"] == self.wxid: # 自己发的消息 message["FromWxid"] = message["ToWxid"] message["IsGroup"] = False logger.info("收到视频消息: 消息ID:{} 来自:{} 发送人:{} XML:{}", message["MsgId"], message["FromWxid"], message["SenderWxid"], str(message["Content"]).replace("\n", "")) await self.msg_db.save_message( msg_id=int(message["MsgId"]), sender_wxid=message["SenderWxid"], from_wxid=message["FromWxid"], msg_type=int(message["MsgType"]), content=message["Content"], is_group=message["IsGroup"] ) message["Video"] = await self.bot.download_video(message["MsgId"]) if self.ignore_check(message["FromWxid"], message["SenderWxid"]): if self.ignore_protection or not protector.check(14400): await EventManager.emit("video_message", self.bot, message) else: logger.warning("风控保护: 新设备登录后4小时内请挂机") async def process_file_message(self, message: Dict[str, Any]): """处理文件消息""" try: root = ET.fromstring(message["Content"]) filename = root.find("appmsg").find("title").text attach_id = root.find("appmsg").find("appattach").find("attachid").text file_extend = root.find("appmsg").find("appattach").find("fileext").text except Exception as error: logger.error(f"解析文件消息失败: {error}") return message["Filename"] = filename message["FileExtend"] = file_extend logger.info("收到文件消息: 消息ID:{} 来自:{} 发送人:{} XML:{}", message["MsgId"], message["FromWxid"], message["SenderWxid"], message["Content"]) await self.msg_db.save_message( msg_id=int(message["MsgId"]), sender_wxid=message["SenderWxid"], from_wxid=message["FromWxid"], msg_type=int(message["MsgType"]), content=message["Content"], is_group=message["IsGroup"] ) message["File"] = await self.bot.download_attach(attach_id) if self.ignore_check(message["FromWxid"], message["SenderWxid"]): if self.ignore_protection or not protector.check(14400): await EventManager.emit("file_message", self.bot, message) else: logger.warning("风控保护: 新设备登录后4小时内请挂机") async def process_system_message(self, message: Dict[str, Any]): """处理系统消息""" # 预处理消息 message["Content"] = message.get("Content").get("string") if message["FromWxid"].endswith("@chatroom"): # 群聊消息 message["IsGroup"] = True split_content = message["Content"].split(":", 1) if len(split_content) > 1: message["Content"] = split_content[1] message["SenderWxid"] = split_content[0] else: # 绝对是自己发的消息! qwq message["Content"] = split_content[0] message["SenderWxid"] = self.wxid else: message["SenderWxid"] = message["FromWxid"] if message["FromWxid"] == self.wxid: # 自己发的消息 message["FromWxid"] = message["ToWxid"] message["IsGroup"] = False try: root = ET.fromstring(message["Content"]) msg_type = root.attrib["type"] except Exception as e: logger.error(f"解析系统消息失败: {e}") return if msg_type == "pat": await self.process_pat_message(message) elif msg_type == "ClientCheckGetExtInfo": pass else: logger.info("收到系统消息: {}", message) if self.ignore_check(message["FromWxid"], message["SenderWxid"]): if self.ignore_protection or not protector.check(14400): await EventManager.emit("system_message", self.bot, message) else: logger.warning("风控保护: 新设备登录后4小时内请挂机") async def process_pat_message(self, message: Dict[str, Any]): """处理拍一拍请求消息""" try: root = ET.fromstring(message["Content"]) pat = root.find("pat") patter = pat.find("fromusername").text patted = pat.find("pattedusername").text pat_suffix = pat.find("patsuffix").text except Exception as e: logger.error(f"解析拍一拍消息失败: {e}") return message["Patter"] = patter message["Patted"] = patted message["PatSuffix"] = pat_suffix logger.info("收到拍一拍消息: 消息ID:{} 来自:{} 发送人:{} 拍者:{} 被拍:{} 后缀:{}", message["MsgId"], message["FromWxid"], message["SenderWxid"], message["Patter"], message["Patted"], message["PatSuffix"]) await self.msg_db.save_message( msg_id=int(message["MsgId"]), sender_wxid=message["SenderWxid"], from_wxid=message["FromWxid"], msg_type=int(message["MsgType"]), content=f"{message['Patter']} 拍了拍 {message['Patted']} {message['PatSuffix']}", is_group=message["IsGroup"] ) if self.ignore_check(message["FromWxid"], message["SenderWxid"]): if self.ignore_protection or not protector.check(14400): await EventManager.emit("pat_message", self.bot, message) else: logger.warning("风控保护: 新设备登录后4小时内请挂机") def ignore_check(self, FromWxid: str, SenderWxid: str): if self.ignore_mode == "Whitelist": return (FromWxid in self.whitelist) or (SenderWxid in self.whitelist) elif self.ignore_mode == "blacklist": return (FromWxid not in self.blacklist) and (SenderWxid not in self.blacklist) else: return True