├── .github ├── actions │ └── setup-python │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── plugin-test.yml │ ├── python-package.yml │ ├── python-publish.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── poetry.lock ├── pyproject.toml ├── src └── nonebot_plugin_mystool │ ├── __init__.py │ ├── _version.py │ ├── api │ ├── __init__.py │ ├── common.py │ ├── game_sign_api.py │ └── myb_missions_api.py │ ├── command │ ├── __init__.py │ ├── address.py │ ├── common.py │ ├── exchange.py │ ├── help.py │ ├── login.py │ ├── plan.py │ ├── setting.py │ └── user_check.py │ ├── model │ ├── __init__.py │ ├── common.py │ ├── config.py │ ├── data.py │ └── upgrade │ │ ├── __init__.py │ │ ├── common.py │ │ ├── configV2.py │ │ └── dataV2.py │ └── utils │ ├── __init__.py │ ├── common.py │ └── good_image.py ├── subscribe ├── config.json └── config_0.2.4_up.json └── tests ├── __init__.py └── plugin_test.py /.github/actions/setup-python/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Python 2 | description: Setup Python 3 | 4 | inputs: 5 | python-version: 6 | description: Python version 7 | required: false 8 | default: "3.11" 9 | 10 | runs: 11 | using: "composite" 12 | steps: 13 | - name: Install poetry 14 | run: pipx install poetry 15 | shell: bash 16 | 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ inputs.python-version }} 20 | architecture: "x64" 21 | cache: "poetry" 22 | 23 | - run: poetry install --with test 24 | shell: bash 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | target-branch: "dev" 8 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | pull_request: 16 | paths: 17 | - 'src/**' 18 | - '.github/workflows/codeql-analysis.yml' 19 | 20 | permissions: 21 | contents: read 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.github/workflows/plugin-test.yml: -------------------------------------------------------------------------------- 1 | name: Nonebot Plugin Test 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ "stable", "dev" ] 7 | paths: 8 | - ".github/actions/setup-python/**" 9 | - ".github/workflows/plugin-test.yml" 10 | - "src/nonebot_plugin_mystool/**" 11 | - "tests/**" 12 | - "pyproject.toml" 13 | - "poetry.lock" 14 | pull_request: 15 | branches: [ "stable", "dev" ] 16 | paths: 17 | - ".github/actions/setup-python/**" 18 | - ".github/workflows/plugin-test.yml" 19 | - "src/nonebot_plugin_mystool/**" 20 | - "tests/**" 21 | - "pyproject.toml" 22 | - "poetry.lock" 23 | 24 | jobs: 25 | plugin_test: 26 | runs-on: ubuntu-latest 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ] 31 | name: nonebot2 plugin test 32 | permissions: 33 | issues: read 34 | outputs: 35 | result: ${{ steps.plugin-test.outputs.RESULT }} 36 | output: ${{ steps.plugin-test.outputs.OUTPUT }} 37 | metadata: ${{ steps.plugin-test.outputs.METADATA }} 38 | steps: 39 | - uses: actions/checkout@v4 40 | - name: Setup Python environment 41 | uses: ./.github/actions/setup-python 42 | with: 43 | python-version: ${{ matrix.python-version }} 44 | - name: Test Plugin 45 | id: plugin-test 46 | run: | 47 | poetry run python -m tests.plugin_test 48 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python package 5 | 6 | on: 7 | workflow_dispatch: 8 | push: 9 | branches: [ "stable", "dev" ] 10 | paths: 11 | - ".github/actions/setup-python/**" 12 | - ".github/workflows/python-package.yml" 13 | - "src/nonebot_plugin_mystool/**" 14 | - "pyproject.toml" 15 | - "poetry.lock" 16 | pull_request: 17 | branches: [ "stable", "dev" ] 18 | paths: 19 | - ".github/actions/setup-python/**" 20 | - ".github/workflows/python-package.yml" 21 | - "src/nonebot_plugin_mystool/**" 22 | - "pyproject.toml" 23 | - "poetry.lock" 24 | 25 | jobs: 26 | build: 27 | 28 | runs-on: ubuntu-latest 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | python-version: ["3.9", "3.10", "3.11", "3.12"] 33 | 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Setup Python environment 37 | uses: ./.github/actions/setup-python 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | - name: Lint with flake8 41 | run: | 42 | # stop the build if there are Python syntax errors or undefined names 43 | poetry run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 44 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 45 | poetry run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 46 | - name: Poetry build 47 | run: | 48 | poetry build -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | workflow_dispatch: 5 | workflow_call: 6 | release: 7 | types: [ published ] 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup Python environment 17 | uses: ./.github/actions/setup-python 18 | with: 19 | python-version: "3.11" 20 | 21 | - name: Build package with Poetry 22 | run: poetry build 23 | 24 | - name: Publish package with Poetry 25 | run: | 26 | poetry config pypi-token.pypi ${{ secrets.PYPI_API_TOKEN }} 27 | poetry publish 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - "*" 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Release 18 | uses: softprops/action-gh-release@v2 19 | if: startsWith(github.ref, 'refs/tags/') 20 | with: 21 | body_path: CHANGELOG.md 22 | prerelease: ${{ contains(github.ref, 'beta') }} 23 | # publish: 24 | # uses: ./.github/workflows/python-publish.yml -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | pytestdebug.log 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | doc/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | # pytype static type analyzer 139 | .pytype/ 140 | 141 | # End of https://www.toptal.com/developers/gitignore/api/python 142 | 143 | .env* 144 | pdm.lock 145 | data/ 146 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 更新内容 2 | 3 | [//]: # (#### 💡 新特性) 4 | 5 | #### 🐛 修复 6 | 7 | - 修复签到、点赞、分享任务失败 (#410) by @Do1e 8 | 9 | [//]: # (#### 🔧 杂项) 10 | 11 | ### 更新方式 12 | 13 | 如果使用的是镜像源,可能需要等待镜像源同步才能更新至最新版 14 | 15 | - 使用 nb-cli 命令: 16 | ``` 17 | nb plugin update nonebot-plugin-mystool 18 | ``` 19 | 20 | - 或 pip 命令(如果使用了虚拟环境,需要先进入虚拟环境): 21 | ``` 22 | pip install --upgrade nonebot-plugin-mystool 23 | ``` 24 | 25 | ### 兼容性 26 | 27 | - V2 (`>=v2.0.0`) 的相关文件为 _`configV2.json`, `dataV2.json`, `.env`_,如果存在 V1 版本的文件,**会自动备份和升级** 28 | - V1 (`>=v1.0.0, [!Note] 17 | > 目前本项目基本不再新增功能,问题修复可能也会比较迟,主要是由于当前项目代码可维护性较差(\ 18 | > 以及个人时间安排等。未来可能考虑彻底重做一次 19 | 20 | ## ⚡ 功能和特性 21 | 22 | - 支持QQ聊天和QQ频道 23 | - 短信验证登录,免抓包获取 Cookie 24 | - 自动完成每日米游币任务 25 | - 自动进行游戏签到 26 | - 可制定米游币商品兑换计划,到点兑换(因加入了人机验证,成功率较低) 27 | - 可支持多个 QQ 账号,每个 QQ 账号可绑定多个米哈游账户 28 | - QQ 推送执行结果通知 29 | - 原神、崩坏:星穹铁道状态便笺通知 30 | - 可为登录、每日米游币任务、游戏签到配置人机验证打码平台 31 | - 可配置用户黑名单/白名单 32 | 33 | ## 📖 使用说明 34 | 35 | ### 🛠️ NoneBot2 机器人部署和插件安装 36 | 37 | 请查看 -> [🔗Installation](https://github.com/Ljzd-PRO/nonebot-plugin-mystool/wiki/Installation) 38 | 39 | ### 📖 插件具体使用说明 40 | 41 | 请查看 -> [🔗Wiki 文档](https://github.com/Ljzd-PRO/nonebot-plugin-mystool/wiki) 42 | 43 | ### ❓ 获取插件帮助信息 44 | 45 | #### 插件命令 46 | 47 | ``` 48 | /帮助 49 | ``` 50 | 51 | > [!NOTE] 52 | > 此处没有使用 [🔗 插件命令头](https://github.com/Ljzd-PRO/nonebot-plugin-mystool/wiki/Configuration-Preference#command_start) 53 | 54 | ## 其他 55 | 56 | ### 贡献 57 | 58 | 贡献者 59 | 60 | 61 | ### 🔨 开发版分支 62 | [**🔨dev**](https://github.com/Ljzd-PRO/nonebot-plugin-mystool/tree/dev) 63 | 64 | ### 📃 源码说明 65 | [📃Source-Structure](https://github.com/Ljzd-PRO/nonebot-plugin-mystool/wiki/Source-Structure) 66 | 67 | ### 适配 [绪山真寻Bot](https://github.com/HibiKier/zhenxun_bot) 的分支 68 | - https://github.com/MWTJC/zhenxun-plugin-mystool 69 | - https://github.com/ayakasuki/nonebot-plugin-mystool 70 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "nonebot-plugin-mystool" 3 | version = "v2.10.1" 4 | description = "QQ聊天、频道机器人插件 | 米游社工具-每日米游币任务、游戏签到、商品兑换、免抓包登录、原神崩铁便笺提醒" 5 | license = "MIT" 6 | authors = [ 7 | "Ljzd-PRO ", 8 | "Everything0519 <598139245@qq.com>" 9 | ] 10 | readme = "README.md" 11 | homepage = "https://github.com/Ljzd-PRO/nonebot-plugin-mystool" 12 | repository = "https://github.com/Ljzd-PRO/nonebot-plugin-mystool" 13 | documentation = "https://github.com/Ljzd-PRO/nonebot-plugin-mystool/wiki" 14 | keywords = ["bot", "qq", "qqbot", "onebotv11", "onebot", "nonebot2", "nonebot", "mihoyo", "mihoyobbs", "qq-guild", "star-rail", "genshin-impact"] 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | "Development Status :: 5 - Production/Stable" 20 | ] 21 | packages = [ 22 | { include = "nonebot_plugin_mystool", from = "src" }, 23 | ] 24 | 25 | [tool.poetry.dependencies] 26 | python = ">=3.9,<4.0" 27 | httpx = ">=0.24.1,<0.29.0" 28 | nonebot_plugin_apscheduler = ">=0.2.0" 29 | nonebot-plugin-send-anything-anywhere = ">=0.5,<0.8" 30 | ntplib = "^0.4.0" 31 | Pillow = ">=9.5,<12.0" 32 | requests = "^2.31.0" 33 | nonebot-adapter-onebot = "^2.3.1" 34 | nonebot-adapter-qq = "^1.1.2" 35 | tenacity = ">=8.2.3,<10.0.0" 36 | qrcode = ">=7.4.2,<9.0.0" 37 | pydantic = "^1.10.14" 38 | nonebot2 = ">=2.0.0" 39 | pytz = ">=2023.4,<2025.0" 40 | 41 | [tool.poetry.group.test.dependencies] 42 | flake8 = ">=6.1,<8.0" 43 | 44 | [tool.poetry.group.test] 45 | optional = true 46 | 47 | [tool.poetry.urls] 48 | "Bug Tracker" = "https://github.com/Ljzd-PRO/nonebot-plugin-mystool/issues" 49 | 50 | [build-system] 51 | requires = ["poetry-core>=1.0.0"] 52 | build-backend = "poetry.core.masonry.api" 53 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/__init__.py: -------------------------------------------------------------------------------- 1 | import nonebot 2 | from nonebot import require 3 | from nonebot.plugin import PluginMetadata 4 | 5 | require("nonebot_plugin_saa") 6 | require("nonebot_plugin_apscheduler") 7 | 8 | _driver = nonebot.get_driver() 9 | _command_begin = list(_driver.config.command_start)[0] 10 | 11 | from . import _version 12 | 13 | __version__ = _version.__version__ 14 | __plugin_meta__ = PluginMetadata( 15 | name="米游社小助手插件\n", 16 | description="米游社工具-每日米游币任务、游戏签到、商品兑换、免抓包登录\n", 17 | type="application", 18 | homepage="https://github.com/Ljzd-PRO/nonebot-plugin-mystool", 19 | supported_adapters={"~onebot.v11", "~qq"}, 20 | usage= 21 | f"\n🔐 {_command_begin}登录 ➢ 登录绑定米游社账户" 22 | f"\n📦 {_command_begin}地址 ➢ 设置收货地址ID" 23 | f"\n🗓️ {_command_begin}签到 ➢ 手动进行游戏签到" 24 | f"\n📅 {_command_begin}任务 ➢ 手动执行米游币任务" 25 | f"\n🛒 {_command_begin}兑换 ➢ 米游币商品兑换相关" 26 | f"\n🎁 {_command_begin}商品 ➢ 查看米游币商品信息(商品ID)" 27 | f"\n📊 {_command_begin}原神便笺 ➢ 查看原神实时便笺(原神树脂、洞天财瓮等)" 28 | f"\n📊 {_command_begin}铁道便笺 ➢ 查看星穹铁道实时便笺(开拓力、每日实训等)" 29 | f"\n⚙️ {_command_begin}设置 ➢ 设置是否开启通知、每日任务等相关选项" 30 | f"\n🔑 {_command_begin}账号设置 ➢ 设置设备平台、是否开启每日计划任务、频道任务" 31 | f"\n🔔 {_command_begin}通知设置 ➢ 设置是否开启每日米游币任务、游戏签到的结果通知" 32 | f"\n🖨️ {_command_begin}导出Cookies ➢ 导出绑定的米游社账号的Cookies数据" 33 | f"\n🖇️ {_command_begin}用户绑定 ➢ 绑定关联其他聊天平台或其他账号的用户数据" 34 | f"\n📨 {_command_begin}私信响应 ➢ 让机器人发送一条私信给你,主要用于QQ频道" 35 | f"\n📖 {_command_begin}帮助 ➢ 查看帮助信息" 36 | f"\n🔍 {_command_begin}帮助 <功能名> ➢ 查看目标功能详细说明" 37 | "\n\n⚠️你的数据将经过机器人服务器,请确定你信任服务器所有者再使用。", 38 | extra={"version": __version__} 39 | ) 40 | 41 | # 升级 V1 版本插件数据文件 42 | 43 | from .model.upgrade import upgrade_plugin_data 44 | 45 | upgrade_plugin_data() 46 | 47 | # 防止多进程生成图片时反复调用 48 | 49 | from .utils import CommandBegin 50 | 51 | _driver.on_startup(CommandBegin.set_command_begin) 52 | 53 | # 加载命令 54 | 55 | from .command import * 56 | 57 | # 加载其他代码 58 | 59 | from .api import * 60 | from .model import * 61 | from .utils import * 62 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "v2.10.1" 2 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .game_sign_api import * 2 | from .myb_missions_api import * 3 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/api/game_sign_api.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Tuple, Literal, Set, Type 2 | from urllib.parse import urlencode 3 | 4 | import httpx 5 | import tenacity 6 | 7 | from ..api.common import ApiResultHandler, HEADERS_API_TAKUMI_MOBILE, is_incorrect_return, \ 8 | device_login, device_save 9 | from ..model import GameRecord, BaseApiStatus, Award, GameSignInfo, GeetestResult, MmtData, plugin_config, plugin_env, \ 10 | UserAccount 11 | from ..utils import logger, generate_ds, \ 12 | get_async_retry 13 | 14 | __all__ = ["BaseGameSign", "GenshinImpactSign", "HonkaiImpact3Sign", "HoukaiGakuen2Sign", "TearsOfThemisSign", 15 | "StarRailSign", "ZenlessZoneZeroSign"] 16 | 17 | 18 | class BaseGameSign: 19 | """ 20 | 游戏签到基类 21 | """ 22 | name: str 23 | """游戏名字""" 24 | en_name: str 25 | act_id: str 26 | url_reward = "https://api-takumi.mihoyo.com/event/luna/home" 27 | url_info = "https://api-takumi.mihoyo.com/event/luna/info" 28 | url_sign = "https://api-takumi.mihoyo.com/event/luna/sign" 29 | headers_general = HEADERS_API_TAKUMI_MOBILE.copy() 30 | headers_reward = { 31 | "Host": "api-takumi.mihoyo.com", 32 | "Origin": "https://webstatic.mihoyo.com", 33 | "Connection": "keep-alive", 34 | "Accept": "application/json, text/plain, */*", 35 | "User-Agent": plugin_env.device_config.USER_AGENT_MOBILE, 36 | "Accept-Language": "zh-CN,zh-Hans;q=0.9", 37 | "Referer": "https://webstatic.mihoyo.com/", 38 | "Accept-Encoding": "gzip, deflate, br" 39 | } 40 | game_id = int 41 | 42 | available_game_signs: Set[Type["BaseGameSign"]] = set() 43 | """可用的子类""" 44 | 45 | def __init__(self, account: UserAccount, records: List[GameRecord]): 46 | self.account = account 47 | self.record = next(filter(lambda x: x.game_id == self.game_id, records), None) 48 | reward_params = { 49 | "lang": "zh-cn", 50 | "act_id": self.act_id 51 | } 52 | self.url_reward = f"{self.url_reward}?{urlencode(reward_params)}" 53 | info_params = { 54 | "lang": "zh-cn", 55 | "act_id": self.act_id, 56 | "region": self.record.region if self.record else None, 57 | "uid": self.record.game_role_id if self.record else None 58 | } 59 | self.url_info = f"{self.url_info}?{urlencode(info_params)}" 60 | 61 | @property 62 | def has_record(self) -> bool: 63 | """ 64 | 是否有游戏账号 65 | """ 66 | return self.record is not None 67 | 68 | async def get_rewards(self, retry: bool = True) -> Tuple[BaseApiStatus, Optional[List[Award]]]: 69 | """ 70 | 获取签到奖励信息 71 | 72 | :param retry: 是否允许重试 73 | """ 74 | try: 75 | async for attempt in get_async_retry(retry): 76 | with attempt: 77 | async with httpx.AsyncClient() as client: 78 | res = await client.get(self.url_reward, headers=self.headers_reward, 79 | timeout=plugin_config.preference.timeout) 80 | award_list = [] 81 | for award in res.json()["data"]["awards"]: 82 | award_list.append(Award.parse_obj(award)) 83 | return BaseApiStatus(success=True), award_list 84 | except tenacity.RetryError as e: 85 | if is_incorrect_return(e): 86 | logger.exception(f"获取签到奖励信息 - 服务器没有正确返回") 87 | logger.debug(f"网络请求返回: {res.text}") 88 | return BaseApiStatus(incorrect_return=True), None 89 | else: 90 | logger.exception(f"获取签到奖励信息 - 请求失败") 91 | return BaseApiStatus(network_error=True), None 92 | 93 | async def get_info( 94 | self, 95 | platform: Literal["ios", "android"] = "ios", 96 | retry: bool = True 97 | ) -> Tuple[BaseApiStatus, Optional[GameSignInfo]]: 98 | """ 99 | 获取签到记录 100 | 101 | :param platform: 使用的设备平台 102 | :param retry: 是否允许重试 103 | """ 104 | headers = self.headers_general.copy() 105 | headers["x-rpc-device_id"] = self.account.device_id_ios if platform == "ios" else self.account.device_id_android 106 | 107 | try: 108 | async for attempt in get_async_retry(retry): 109 | with attempt: 110 | headers["DS"] = generate_ds() if platform == "ios" else generate_ds(platform="android") 111 | async with httpx.AsyncClient() as client: 112 | res = await client.get(self.url_info, headers=headers, 113 | cookies=self.account.cookies.dict(), 114 | timeout=plugin_config.preference.timeout) 115 | api_result = ApiResultHandler(res.json()) 116 | if api_result.login_expired: 117 | logger.info( 118 | f"获取签到数据 - 用户 {self.account.display_name} 登录失效") 119 | logger.debug(f"网络请求返回: {res.text}") 120 | return BaseApiStatus(login_expired=True), None 121 | if api_result.invalid_ds: 122 | logger.info( 123 | f"获取签到数据 - 用户 {self.account.display_name} DS 校验失败") 124 | logger.debug(f"网络请求返回: {res.text}") 125 | return BaseApiStatus(invalid_ds=True), None 126 | return BaseApiStatus(success=True), GameSignInfo.parse_obj(api_result.data) 127 | except tenacity.RetryError as e: 128 | if is_incorrect_return(e): 129 | logger.exception(f"获取签到数据 - 服务器没有正确返回") 130 | logger.debug(f"网络请求返回: {res.text}") 131 | return BaseApiStatus(incorrect_return=True), None 132 | else: 133 | logger.exception(f"获取签到数据 - 请求失败") 134 | return BaseApiStatus(network_error=True), None 135 | 136 | async def sign(self, 137 | platform: Literal["ios", "android"] = "ios", 138 | mmt_data: MmtData = None, 139 | geetest_result: GeetestResult = None, 140 | retry: bool = True) -> Tuple[BaseApiStatus, Optional[MmtData]]: 141 | """ 142 | 签到 143 | 144 | :param platform: 设备平台 145 | :param mmt_data: 人机验证任务 146 | :param geetest_result: 用于执行签到的人机验证结果 147 | :param retry: 是否允许重试 148 | """ 149 | if not self.record: 150 | return BaseApiStatus(success=True), None 151 | content = { 152 | "act_id": self.act_id, 153 | "region": self.record.region, 154 | "uid": self.record.game_role_id 155 | } 156 | headers = self.headers_general.copy() 157 | if platform == "ios": 158 | headers["x-rpc-device_id"] = self.account.device_id_ios 159 | headers["Sec-Fetch-Dest"] = "empty" 160 | headers["Sec-Fetch-Site"] = "same-site" 161 | headers["DS"] = generate_ds() 162 | else: 163 | await device_login(self.account) 164 | await device_save(self.account) 165 | headers["x-rpc-device_id"] = self.account.device_id_android 166 | headers["x-rpc-device_model"] = plugin_env.device_config.X_RPC_DEVICE_MODEL_ANDROID 167 | headers["User-Agent"] = plugin_env.device_config.USER_AGENT_ANDROID 168 | headers["x-rpc-device_name"] = plugin_env.device_config.X_RPC_DEVICE_NAME_ANDROID 169 | headers["x-rpc-channel"] = plugin_env.device_config.X_RPC_CHANNEL_ANDROID 170 | headers["x-rpc-sys_version"] = plugin_env.device_config.X_RPC_SYS_VERSION_ANDROID 171 | headers["x-rpc-client_type"] = "2" 172 | headers["DS"] = generate_ds(data=content) 173 | headers.pop("x-rpc-platform") 174 | 175 | try: 176 | async for attempt in get_async_retry(retry): 177 | with attempt: 178 | if geetest_result: 179 | headers["x-rpc-validate"] = geetest_result.validate 180 | headers["x-rpc-challenge"] = mmt_data.challenge 181 | headers["x-rpc-seccode"] = geetest_result.seccode 182 | logger.info("游戏签到 - 尝试使用人机验证结果进行签到") 183 | 184 | async with httpx.AsyncClient() as client: 185 | res = await client.post( 186 | self.url_sign, 187 | headers=headers, 188 | cookies=self.account.cookies.dict(), 189 | timeout=plugin_config.preference.timeout, 190 | json=content 191 | ) 192 | 193 | api_result = ApiResultHandler(res.json()) 194 | if api_result.login_expired: 195 | logger.info( 196 | f"游戏签到 - 用户 {self.account.display_name} 登录失效") 197 | logger.debug(f"网络请求返回: {res.text}") 198 | return BaseApiStatus(login_expired=True), None 199 | elif api_result.invalid_ds: 200 | logger.info( 201 | f"游戏签到 - 用户 {self.account.display_name} DS 校验失败") 202 | logger.debug(f"网络请求返回: {res.text}") 203 | return BaseApiStatus(invalid_ds=True), None 204 | elif api_result.data.get("risk_code") != 0: 205 | logger.warning( 206 | f"{plugin_config.preference.log_head}游戏签到 - 用户 {self.account.display_name} 可能被人机验证阻拦") 207 | logger.debug(f"{plugin_config.preference.log_head}网络请求返回: {res.text}") 208 | return BaseApiStatus(need_verify=True), MmtData.parse_obj(api_result.data) 209 | else: 210 | logger.success(f"游戏签到 - 用户 {self.account.display_name} 签到成功") 211 | logger.debug(f"网络请求返回: {res.text}") 212 | return BaseApiStatus(success=True), None 213 | 214 | except tenacity.RetryError as e: 215 | if is_incorrect_return(e): 216 | logger.exception(f"游戏签到 - 服务器没有正确返回") 217 | logger.debug(f"网络请求返回: {res.text}") 218 | return BaseApiStatus(incorrect_return=True), None 219 | else: 220 | logger.exception(f"游戏签到 - 请求失败") 221 | return BaseApiStatus(network_error=True), None 222 | 223 | 224 | class GenshinImpactSign(BaseGameSign): 225 | """ 226 | 原神 游戏签到 227 | """ 228 | name = "原神" 229 | en_name = "GenshinImpact" 230 | act_id = "e202311201442471" 231 | game_id = 2 232 | headers_general = BaseGameSign.headers_general.copy() 233 | headers_reward = BaseGameSign.headers_reward.copy() 234 | for headers in headers_general, headers_reward: 235 | headers["x-rpc-signgame"] = "hk4e" 236 | headers["Origin"] = "https://act.mihoyo.com" 237 | headers["Referer"] = "https://act.mihoyo.com/" 238 | 239 | 240 | class HonkaiImpact3Sign(BaseGameSign): 241 | """ 242 | 崩坏3 游戏签到 243 | """ 244 | name = "崩坏3" 245 | en_name = "HonkaiImpact3" 246 | act_id = "e202306201626331" 247 | game_id = 1 248 | 249 | 250 | class HoukaiGakuen2Sign(BaseGameSign): 251 | """ 252 | 崩坏学园2 游戏签到 253 | """ 254 | name = "崩坏学园2" 255 | en_name = "HoukaiGakuen2" 256 | act_id = "e202203291431091" 257 | game_id = 3 258 | 259 | 260 | class TearsOfThemisSign(BaseGameSign): 261 | """ 262 | 未定事件簿 游戏签到 263 | """ 264 | name = "未定事件簿" 265 | en_name = "TearsOfThemis" 266 | act_id = "e202202251749321" 267 | game_id = 4 268 | 269 | 270 | class StarRailSign(BaseGameSign): 271 | """ 272 | 崩坏:星穹铁道 游戏签到 273 | """ 274 | name = "崩坏:星穹铁道" 275 | en_name = "StarRail" 276 | act_id = "e202304121516551" 277 | game_id = 6 278 | 279 | 280 | class ZenlessZoneZeroSign(BaseGameSign): 281 | """ 282 | 绝区零 游戏签到 283 | """ 284 | name = "绝区零" 285 | en_name = "ZenlessZoneZero" 286 | act_id = "e202406242138391" 287 | game_id = 8 288 | url_reward = "https://act-nap-api.mihoyo.com/event/luna/zzz/home" 289 | url_info = "https://act-nap-api.mihoyo.com/event/luna/zzz/info" 290 | url_sign = "https://act-nap-api.mihoyo.com/event/luna/zzz/sign" 291 | headers_general = BaseGameSign.headers_general.copy() 292 | headers_reward = BaseGameSign.headers_reward.copy() 293 | for headers in headers_general, headers_reward: 294 | headers["x-rpc-signgame"] = "zzz" 295 | headers["Origin"] = "https://act.mihoyo.com" 296 | headers["Referer"] = "https://act.mihoyo.com/" 297 | headers["Host"] = "act-nap-api.mihoyo.com" 298 | 299 | 300 | BaseGameSign.available_game_signs.add(GenshinImpactSign) 301 | BaseGameSign.available_game_signs.add(HonkaiImpact3Sign) 302 | BaseGameSign.available_game_signs.add(HoukaiGakuen2Sign) 303 | BaseGameSign.available_game_signs.add(TearsOfThemisSign) 304 | BaseGameSign.available_game_signs.add(StarRailSign) 305 | BaseGameSign.available_game_signs.add(ZenlessZoneZeroSign) 306 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/command/__init__.py: -------------------------------------------------------------------------------- 1 | from .address import * 2 | from .common import * 3 | from .exchange import * 4 | from .help import * 5 | from .login import * 6 | from .plan import * 7 | from .setting import * 8 | from .user_check import * 9 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/command/address.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Union 3 | 4 | from nonebot import on_command 5 | from nonebot.internal.params import ArgStr 6 | from nonebot.matcher import Matcher 7 | from nonebot.params import T_State 8 | 9 | from ..api.common import get_address 10 | from ..command.common import CommandRegistry 11 | from ..model import CommandUsage 12 | from ..model import PluginDataManager, plugin_config, UserAccount 13 | from ..utils import COMMAND_BEGIN, GeneralMessageEvent, GeneralPrivateMessageEvent, \ 14 | GeneralGroupMessageEvent 15 | 16 | __all__ = [ 17 | "address_matcher" 18 | ] 19 | 20 | address_matcher = on_command(plugin_config.preference.command_start + '地址', priority=4, block=True) 21 | 22 | CommandRegistry.set_usage( 23 | address_matcher, 24 | CommandUsage( 25 | name="地址", 26 | description="跟随指引,获取地址ID,用于兑换米游币商品。在获取地址ID前,如果你还没有设置米游社收获地址,请前往官网或App设置" 27 | ) 28 | ) 29 | 30 | 31 | @address_matcher.handle() 32 | async def _(event: Union[GeneralMessageEvent], matcher: Matcher, state: T_State): 33 | if isinstance(event, GeneralGroupMessageEvent): 34 | await address_matcher.finish("⚠️为了保护您的隐私,请私聊进行地址设置。") 35 | user = PluginDataManager.plugin_data.users.get(event.get_user_id()) 36 | user_account = user.accounts if user else None 37 | if not user_account: 38 | await address_matcher.finish(f"⚠️你尚未绑定米游社账户,请先使用『{COMMAND_BEGIN}登录』进行登录") 39 | else: 40 | await address_matcher.send( 41 | "请跟随指引设置收货地址ID,如果你还没有设置米游社收获地址,请前往官网或App设置。\n🚪过程中发送“退出”即可退出") 42 | if len(user_account) == 1: 43 | account = next(iter(user_account.values())) 44 | state["bbs_uid"] = account.bbs_uid 45 | else: 46 | msg = "您有多个账号,您要设置以下哪个账号的收货地址?\n" 47 | msg += "\n".join(map(lambda x: f"🆔{x}", user_account)) 48 | await matcher.send(msg) 49 | 50 | 51 | @address_matcher.got('bbs_uid') 52 | async def _(event: Union[GeneralPrivateMessageEvent], state: T_State, bbs_uid=ArgStr()): 53 | if bbs_uid == '退出': 54 | await address_matcher.finish('🚪已成功退出') 55 | 56 | user_account = PluginDataManager.plugin_data.users[event.get_user_id()].accounts 57 | if bbs_uid not in user_account: 58 | await address_matcher.reject('⚠️您发送的账号不在以上账号内,请重新发送') 59 | account = user_account[bbs_uid] 60 | state['account'] = account 61 | 62 | address_status, address_list = await get_address(account) 63 | state['address_list'] = address_list 64 | if not address_status: 65 | if address_status.login_expired: 66 | await address_matcher.finish(f"⚠️账户 {account.display_name} 登录失效,请重新登录") 67 | await address_matcher.finish("⚠️获取失败,请稍后重新尝试") 68 | 69 | if address_list: 70 | address_text = map( 71 | lambda x: f"省 ➢ {x.province_name}\n" 72 | f"市 ➢ {x.city_name}\n" 73 | f"区/县 ➢ {x.county_name}\n" 74 | f"详细地址 ➢ {x.addr_ext}\n" 75 | f"联系电话 ➢ {x.phone}\n" 76 | f"联系人 ➢ {x.connect_name}\n" 77 | f"地址ID ➢ {x.id}", 78 | address_list 79 | ) 80 | msg = "以下为查询结果:" \ 81 | "\n\n" + "\n- - -\n".join(address_text) 82 | await address_matcher.send(msg) 83 | await asyncio.sleep(0.2) 84 | else: 85 | await address_matcher.finish("⚠️您还没有配置地址,请先前往米游社配置地址!") 86 | 87 | 88 | @address_matcher.got('address_id', prompt='请发送你要选择的地址ID') 89 | async def _(_: Union[GeneralPrivateMessageEvent], state: T_State, address_id=ArgStr()): 90 | if address_id == "退出": 91 | await address_matcher.finish("🚪已成功退出") 92 | 93 | address_filter = filter(lambda x: x.id == address_id, state['address_list']) 94 | address = next(address_filter, None) 95 | if address is not None: 96 | account: UserAccount = state["account"] 97 | account.address = address 98 | PluginDataManager.write_plugin_data() 99 | await address_matcher.finish(f"🎉已成功设置账户 {account.display_name} 的地址") 100 | else: 101 | await address_matcher.reject("⚠️您发送的地址ID与查询结果不匹配,请重新发送") 102 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/command/common.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Type, Optional 2 | 3 | from nonebot.internal.matcher import Matcher 4 | 5 | from ..model import CommandUsage 6 | 7 | __all__ = ["CommandRegistry"] 8 | 9 | 10 | class CommandRegistry: 11 | _command_to_usage: Dict[Type[Matcher], CommandUsage] = {} 12 | 13 | @classmethod 14 | def get_commands_usage_mapping(cls) -> Dict[Type[Matcher], CommandUsage]: 15 | return cls._command_to_usage.copy() 16 | 17 | @classmethod 18 | def set_usage(cls, command: Type[Matcher], usage: CommandUsage) -> None: 19 | cls._command_to_usage[command] = usage 20 | 21 | @classmethod 22 | def get_usage(cls, command: Type[Matcher]) -> Optional[CommandUsage]: 23 | return cls._command_to_usage.get(command) 24 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/command/exchange.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import io 3 | import os 4 | import random 5 | import threading 6 | import time 7 | from datetime import datetime 8 | from multiprocessing import Manager 9 | from multiprocessing.pool import Pool 10 | from multiprocessing.synchronize import Lock 11 | from typing import List, Callable, Any, Tuple, Optional, Dict, Union 12 | 13 | from apscheduler.events import JobExecutionEvent, EVENT_JOB_EXECUTED 14 | from nonebot import on_command, get_driver 15 | from nonebot.adapters.onebot.v11 import MessageEvent as OneBotV11MessageEvent, MessageSegment as OneBotV11MessageSegment 16 | from nonebot.adapters.qq import MessageEvent as QQGuildMessageEvent, MessageSegment as QQGuildMessageSegment 17 | from nonebot.internal.params import ArgStr 18 | from nonebot.matcher import Matcher 19 | from nonebot.params import ArgPlainText, T_State, CommandArg, Command 20 | from nonebot_plugin_apscheduler import scheduler 21 | 22 | from ..api.common import get_game_record, get_good_detail, get_good_list, good_exchange_sync, \ 23 | get_device_fp, \ 24 | good_exchange 25 | from ..command.common import CommandRegistry 26 | from ..model import Good, GameRecord, ExchangeStatus, PluginDataManager, plugin_config, UserAccount, \ 27 | ExchangePlan, ExchangeResult, CommandUsage 28 | from ..utils import COMMAND_BEGIN, logger, get_last_command_sep, GeneralMessageEvent, \ 29 | send_private_msg, get_unique_users, \ 30 | get_all_bind, game_list_to_image 31 | 32 | __all__ = [ 33 | "myb_exchange_plan", "get_good_image", "generate_image" 34 | ] 35 | 36 | _driver = get_driver() 37 | 38 | myb_exchange_plan = on_command( 39 | f"{plugin_config.preference.command_start}兑换", 40 | aliases={ 41 | (f"{plugin_config.preference.command_start}兑换", "+"), 42 | (f"{plugin_config.preference.command_start}兑换", "-") 43 | }, 44 | priority=5, 45 | block=True 46 | ) 47 | 48 | CommandRegistry.set_usage( 49 | myb_exchange_plan, 50 | CommandUsage( 51 | name="兑换", 52 | description="跟随指引,配置米游币商品自动兑换计划。添加计划之前,请先前往米游社设置好收货地址," 53 | "并使用『{HEAD}地址』选择你要使用的地址。" 54 | "所需的商品ID可通过命令『{HEAD}商品』获取。" 55 | "注意,不限兑换时间的商品将不会在此处显示。", 56 | usage="具体用法:\n" 57 | "🛒 {HEAD}兑换{SEP}+ <商品ID> ➢ 新增兑换计划\n" 58 | "🗑️ {HEAD}兑换{SEP}- <商品ID> ➢ 删除兑换计划\n" 59 | "🎁 {HEAD}商品 ➢ 查看米游社商品\n" 60 | "『{SEP}』为分隔符,使用NoneBot配置中的其他分隔符亦可" 61 | ) 62 | ) 63 | myb_exchange_plan_usage = CommandRegistry.get_usage(myb_exchange_plan) 64 | 65 | 66 | @myb_exchange_plan.handle() 67 | async def _( 68 | event: Union[GeneralMessageEvent], 69 | matcher: Matcher, 70 | state: T_State, 71 | command=Command(), 72 | command_arg=CommandArg() 73 | ): 74 | """ 75 | 主命令触发 76 | 77 | :command: 主命令和二级命令的元组 78 | :command_arg: 二级命令的参数,即商品ID,为Message 79 | """ 80 | if command_arg and len(command) == 1: 81 | # 如果没有二级命令,但是有参数,则说明用户没有意向使用本功能。 82 | # 例如:/兑换码获取,识别到的参数为"码获取",而用户可能有意使用其他插件。 83 | await matcher.finish() 84 | elif len(command) > 1: 85 | if not command_arg: 86 | await matcher.reject( 87 | '⚠️您的输入有误,缺少商品ID,请重新输入\n\n' 88 | f"{myb_exchange_plan_usage.usage.format(HEAD=COMMAND_BEGIN, SEP=get_last_command_sep())}" 89 | ) 90 | elif not str(command_arg).isdigit(): 91 | await matcher.reject( 92 | '⚠️商品ID必须为数字,请重新输入\n\n' 93 | f"{myb_exchange_plan_usage.usage.format(HEAD=COMMAND_BEGIN, SEP=get_last_command_sep())}" 94 | ) 95 | 96 | user = PluginDataManager.plugin_data.users.get(event.get_user_id()) 97 | user_account = user.accounts if user else None 98 | if not user_account: 99 | await matcher.finish( 100 | f"⚠️你尚未绑定米游社账户,请先使用『{COMMAND_BEGIN}登录』进行登录") 101 | 102 | # 如果使用了二级命令 + - 则跳转进下一步,通过phone选择账户进行设置 103 | if len(command) > 1: 104 | state['command_2'] = command[1] 105 | matcher.set_arg("good_id", command_arg) 106 | if len(user_account) == 1: 107 | uid = next(iter(user_account.values())).bbs_uid 108 | state["bbs_uid"] = uid 109 | else: 110 | msg = "您有多个账号,您要配置以下哪个账号的兑换计划?\n" 111 | msg += "\n".join(map(lambda x: f"🆔{x}", user_account)) 112 | msg += "\n🚪发送“退出”即可退出" 113 | await matcher.send(msg) 114 | # 如果未使用二级命令,则进行查询操作,并结束交互 115 | else: 116 | msg = "" 117 | for plan in user.exchange_plans: 118 | good_detail_status, good = await get_good_detail(plan.good) 119 | if not good_detail_status: 120 | await matcher.finish("⚠️获取商品详情失败,请稍后再试") 121 | msg += f"-- 商品:{good.general_name}" \ 122 | f"\n- 🔢商品ID:{good.goods_id}" \ 123 | f"\n- 💰商品价格:{good.price} 米游币" \ 124 | f"\n- 📅兑换时间:{good.time_text}" \ 125 | f"\n- 🆔账户:{plan.account.display_name}" 126 | msg += "\n\n" 127 | if not msg: 128 | msg = '您还没有兑换计划哦~\n\n' 129 | await matcher.finish( 130 | f"{msg}{myb_exchange_plan_usage.usage.format(HEAD=COMMAND_BEGIN, SEP=get_last_command_sep())}" 131 | ) 132 | 133 | 134 | @myb_exchange_plan.got('bbs_uid') 135 | async def _( 136 | event: Union[GeneralMessageEvent], 137 | matcher: Matcher, 138 | state: T_State, 139 | bbs_uid=ArgStr() 140 | ): 141 | """ 142 | 请求用户输入手机号以对账户设置兑换计划 143 | """ 144 | if bbs_uid == '退出': 145 | await matcher.finish('🚪已成功退出') 146 | user_account = PluginDataManager.plugin_data.users[event.get_user_id()].accounts 147 | if bbs_uid in user_account: 148 | state["account"] = user_account[bbs_uid] 149 | else: 150 | await matcher.reject('⚠️您发送的账号不在以上账号内,请重新发送') 151 | 152 | 153 | @myb_exchange_plan.got('good_id') 154 | async def _( 155 | event: Union[GeneralMessageEvent], 156 | matcher: Matcher, 157 | state: T_State, 158 | good_id=ArgPlainText('good_id') 159 | ): 160 | """ 161 | 处理三级命令,即商品ID 162 | """ 163 | account: UserAccount = state['account'] 164 | command_2 = state['command_2'] 165 | if command_2 == '+': 166 | good_dict = { 167 | 'bh3': (await get_good_list('bh3'))[1], 168 | 'ys': (await get_good_list('hk4e'))[1], 169 | 'bh2': (await get_good_list('bh2'))[1], 170 | 'xq': (await get_good_list('hkrpg'))[1], 171 | 'wd': (await get_good_list('nxx'))[1], 172 | 'bbs': (await get_good_list('bbs'))[1], 173 | 'nap': (await get_good_list('nap'))[1] 174 | } 175 | flag = True 176 | break_flag = False 177 | good = None 178 | for good_list in good_dict.values(): 179 | goods_on_sell = filter(lambda x: not x.time_end and x.time_limited, good_list) 180 | for good in goods_on_sell: 181 | if good.goods_id == good_id: 182 | flag = False 183 | break_flag = True 184 | break 185 | if break_flag: 186 | break 187 | if flag: 188 | await matcher.finish('⚠️您发送的商品ID不在可兑换的商品列表内,程序已退出') 189 | state['good'] = good 190 | if good.time: 191 | # 若为实物商品,也进入下一步骤,但是传入uid为None 192 | if good.is_virtual: 193 | game_records_status, records = await get_game_record(account) 194 | if game_records_status: 195 | if len(records) == 0: 196 | state['game_uid'] = records[0].game_role_id 197 | else: 198 | msg = f'您米游社账户下的游戏账号:' 199 | for record in records: 200 | msg += f'\n🎮 {record.region_name} - {record.nickname} - UID {record.game_role_id}' 201 | if records: 202 | state['records'] = records 203 | await matcher.send( 204 | "您兑换的是虚拟物品,请发送想要接收奖励的游戏账号UID:\n🚪发送“退出”即可退出") 205 | await asyncio.sleep(0.5) 206 | await matcher.send(msg) 207 | else: 208 | await matcher.finish( 209 | f"您的米游社账户下还没有绑定游戏账号哦,暂时不能进行兑换,请先前往米游社绑定后重试") 210 | else: 211 | await matcher.finish('⚠️获取游戏账号列表失败,无法继续') 212 | else: 213 | if not account.address: 214 | await matcher.finish('⚠️您还没有配置地址哦,请先配置地址') 215 | state['game_uid'] = '' 216 | else: 217 | await matcher.finish(f'⚠️该商品暂时不可以兑换,请重新设置') 218 | 219 | elif command_2 == '-': 220 | plans = PluginDataManager.plugin_data.users[event.get_user_id()].exchange_plans 221 | if plans: 222 | for plan in plans: 223 | if plan.good.goods_id == good_id: 224 | plans.discard(plan) 225 | PluginDataManager.write_plugin_data() 226 | for i in range(plugin_config.preference.exchange_thread_count): 227 | try: 228 | scheduler.remove_job(job_id=f"exchange-plan-{hash(plan)}-{i}") 229 | except scheduler.JobLookupError: 230 | # TODO: 原因不明,暂时忽略异常 231 | pass 232 | await matcher.finish('兑换计划删除成功') 233 | await matcher.finish(f"您没有设置商品ID为 {good_id} 的兑换哦~") 234 | else: 235 | await matcher.finish("您还没有配置兑换计划哦~") 236 | 237 | else: 238 | await matcher.reject( 239 | f"⚠️您的输入有误,请重新输入\n\n{myb_exchange_plan_usage.usage.format(HEAD=COMMAND_BEGIN, SEP=get_last_command_sep())}" 240 | ) 241 | 242 | 243 | @myb_exchange_plan.got('game_uid') 244 | async def _( 245 | event: Union[GeneralMessageEvent], 246 | matcher: Matcher, 247 | state: T_State, 248 | game_uid=ArgStr() 249 | ): 250 | """ 251 | 初始化商品兑换任务,如果传入UID为None则为实物商品,仍可继续 252 | """ 253 | user = PluginDataManager.plugin_data.users[event.get_user_id()] 254 | account: UserAccount = state['account'] 255 | good: Good = state['good'] 256 | if good.is_virtual: 257 | records: List[GameRecord] = state['records'] 258 | if game_uid == '退出': 259 | await matcher.finish('🚪已成功退出') 260 | record_filter = filter(lambda x: x.game_role_id == game_uid, records) 261 | record = next(record_filter, None) 262 | if not record: 263 | await matcher.reject('⚠️您输入的UID不在上述账号内,请重新输入') 264 | plan = ExchangePlan(good=good, address=account.address, game_record=record, account=account) 265 | else: 266 | plan = ExchangePlan(good=good, address=account.address, account=account) 267 | if plan in user.exchange_plans: 268 | await matcher.finish('⚠️您已经配置过该商品的兑换哦!') 269 | else: 270 | user.exchange_plans.add(plan) 271 | if not plan.account.device_fp: 272 | logger.info(f"账号 {plan.account.display_name} 未设置 device_fp,正在获取...") 273 | fp_status, plan.account.device_fp = await get_device_fp(plan.account.device_id_ios) 274 | if not fp_status: 275 | await matcher.send( 276 | '⚠️从服务器获取device_fp失败!兑换时将在本地生成device_fp。你也可以尝试重新添加兑换计划。') 277 | PluginDataManager.write_plugin_data() 278 | 279 | # 初始化兑换任务 280 | finished.setdefault(plan, []) 281 | for i in range(plugin_config.preference.exchange_thread_count): 282 | scheduler.add_job( 283 | good_exchange_sync, 284 | "date", 285 | id=f"exchange-plan-{hash(plan)}-{i}", 286 | replace_existing=True, 287 | args=(plan,), 288 | run_date=datetime.fromtimestamp(good.time), 289 | max_instances=plugin_config.preference.exchange_thread_count 290 | ) 291 | 292 | await matcher.finish( 293 | f'🎉设置兑换计划成功!将于 {plan.good.time_text} 开始兑换,到时将会私聊告知您兑换结果') 294 | 295 | 296 | get_good_image = on_command(plugin_config.preference.command_start + '商品', priority=5, block=True) 297 | 298 | CommandRegistry.set_usage( 299 | get_good_image, 300 | CommandUsage( 301 | name="商品", 302 | description="获取当日米游币商品信息。添加自动兑换计划需要商品ID,请记下您要兑换的商品的ID。" 303 | ) 304 | ) 305 | 306 | 307 | @get_good_image.handle() 308 | async def _(_: Union[GeneralMessageEvent], matcher: Matcher, arg=CommandArg()): 309 | # 若有使用二级命令,即传入了想要查看的商品类别,则跳过询问 310 | if arg: 311 | matcher.set_arg("content", arg) 312 | 313 | 314 | @get_good_image.got("content", prompt="请发送您要查看的商品类别:" 315 | "\n- 崩坏3" 316 | "\n- 原神" 317 | "\n- 崩坏2" 318 | "\n- 崩坏:星穹铁道" 319 | "\n- 未定事件簿" 320 | "\n- 绝区零" 321 | "\n- 米游社" 322 | "\n若是商品图片与米游社商品不符或报错 请发送“更新”哦~" 323 | "\n—— 🚪发送“退出”以结束") 324 | async def _(event: Union[GeneralMessageEvent], arg=ArgPlainText("content")): 325 | """ 326 | 根据传入的商品类别,发送对应的商品列表图片 327 | """ 328 | if arg == '退出': 329 | await get_good_image.finish('🚪已成功退出') 330 | elif arg in ['原神', 'ys']: 331 | arg = ('hk4e', '原神') 332 | elif arg in ['崩坏3', '崩坏三', '崩3', '崩三', '崩崩崩', '蹦蹦蹦', 'bh3']: 333 | arg = ('bh3', '崩坏3') 334 | elif arg in ['崩坏2', '崩坏二', '崩2', '崩二', '崩崩', '蹦蹦', 'bh2']: 335 | arg = ('bh2', '崩坏2') 336 | elif arg in ['崩坏:星穹铁道', '星铁', '星穹铁道', '铁道', '轨子', '星穹', 'xq']: 337 | arg = ('hkrpg', '崩坏:星穹铁道') 338 | elif arg in ['未定', '未定事件簿', 'wd']: 339 | arg = ('nxx', '未定事件簿') 340 | elif arg in ['大别野', '米游社', '综合']: 341 | arg = ('bbs', '米游社') 342 | elif arg in ['绝区零']: 343 | arg = ('nap', '绝区零') 344 | elif arg == '更新': 345 | threading.Thread(target=generate_image, kwargs={"is_auto": False}).start() 346 | await get_good_image.finish('⏳后台正在生成商品信息图片,请稍后查询') 347 | else: 348 | await get_good_image.reject('⚠️您的输入有误,请重新输入') 349 | 350 | img_path = time.strftime( 351 | f'{plugin_config.good_list_image_config.SAVE_PATH}/%m-%d-{arg[0]}.jpg', time.localtime()) 352 | if os.path.exists(img_path): 353 | with open(img_path, 'rb') as f: 354 | image_bytes = io.BytesIO(f.read()) 355 | msg = None 356 | if isinstance(event, OneBotV11MessageEvent): 357 | msg = OneBotV11MessageSegment.image(image_bytes) 358 | elif isinstance(event, QQGuildMessageEvent): 359 | msg = QQGuildMessageSegment.file_image(image_bytes) 360 | await get_good_image.finish(msg) 361 | else: 362 | await get_good_image.finish( 363 | f'{arg[1]} 分区暂时没有可兑换的限时商品。如果这与实际不符,你可以尝试用『{COMMAND_BEGIN}商品 更新』进行更新。') 364 | 365 | 366 | lock = threading.Lock() 367 | finished: Dict[ExchangePlan, List[bool]] = {} 368 | 369 | 370 | @lambda func: scheduler.add_listener(func, EVENT_JOB_EXECUTED) 371 | def exchange_notice(event: JobExecutionEvent): 372 | """ 373 | 接收兑换结果 374 | """ 375 | if event.job_id.startswith("exchange-plan"): 376 | loop = asyncio.get_event_loop() 377 | 378 | thread_id = int(event.job_id.split('-')[-1]) + 1 379 | result: Tuple[ExchangeStatus, Optional[ExchangeResult]] = event.retval 380 | exchange_status, exchange_result = result 381 | 382 | if not exchange_status: 383 | hash_value = int(event.job_id.split('-')[-2]) 384 | user_id_filter = filter(lambda x: hash(x[1].exchange_plans) == hash_value, get_unique_users()) 385 | user_id = next(user_id_filter) 386 | user_ids = [user_id] + list(get_all_bind(user_id)) 387 | plans = PluginDataManager.plugin_data.users[user_id].exchange_plans 388 | 389 | with lock: 390 | for plan in plans: 391 | finished[plan].append(False) 392 | for _user_id in user_ids: 393 | loop.create_task( 394 | send_private_msg( 395 | user_id=_user_id, 396 | message=f"⚠️账户 {plan.account.display_name}" 397 | f"\n- {plan.good.general_name}" 398 | f"\n- 线程 {thread_id}" 399 | f"\n- 兑换请求发送失败" 400 | ) 401 | ) 402 | if len(finished[plan]) == plugin_config.preference.exchange_thread_count: 403 | del plan 404 | PluginDataManager.write_plugin_data() 405 | 406 | else: 407 | plan = exchange_result.plan 408 | user_filter = filter(lambda x: plan in x[1].exchange_plans, get_unique_users()) 409 | user_id, user = next(user_filter) 410 | user_ids = [user_id] + list(get_all_bind(user_id)) 411 | with lock: 412 | # 如果已经有一个线程兑换成功,就不再接收结果 413 | if True not in finished[plan]: 414 | if exchange_result.result: 415 | finished[plan].append(True) 416 | for _user_id in user_ids: 417 | loop.create_task( 418 | send_private_msg( 419 | user_id=_user_id, 420 | message=f"🎉账户 {plan.account.display_name}" 421 | f"\n- {plan.good.general_name}" 422 | f"\n- 线程 {thread_id}" 423 | f"\n- 兑换成功" 424 | ) 425 | ) 426 | else: 427 | finished[plan].append(False) 428 | for _user_id in user_ids: 429 | loop.create_task( 430 | send_private_msg( 431 | user_id=_user_id, 432 | message=f"💦账户 {plan.account.display_name}" 433 | f"\n- {plan.good.general_name}" 434 | f"\n- 线程 {thread_id}" 435 | f"\n- 兑换失败" 436 | ) 437 | ) 438 | 439 | if len(finished[plan]) == plugin_config.preference.exchange_thread_count: 440 | try: 441 | user.exchange_plans.remove(plan) 442 | except KeyError: 443 | pass 444 | else: 445 | PluginDataManager.write_plugin_data() 446 | 447 | 448 | async def exchange_begin(plan: ExchangePlan): 449 | """ 450 | 到点后执行兑换 451 | 452 | :param plan: 兑换计划 453 | """ 454 | duration = 0 455 | random_x, random_y = plugin_config.preference.exchange_latency 456 | exchange_status, exchange_result = ExchangeStatus(), None 457 | 458 | # 在兑换开始后的一段时间内,不断尝试兑换,直到成功(因为太早兑换可能被认定不在兑换时间) 459 | while duration < plugin_config.preference.exchange_duration: 460 | latency = random.uniform(random_x, random_y) 461 | time.sleep(latency) 462 | exchange_status, exchange_result = await good_exchange(plan) 463 | if exchange_status and exchange_result.result: 464 | break 465 | duration += latency 466 | return exchange_status, exchange_result 467 | 468 | 469 | @_driver.on_startup 470 | async def _(): 471 | """ 472 | 启动机器人时自动初始化兑换任务 473 | """ 474 | for user_id, user in PluginDataManager.plugin_data.users.items(): 475 | plans = user.exchange_plans 476 | for plan in plans: 477 | good_detail_status, good = await get_good_detail(plan.good) 478 | if not good_detail_status or not good.time or good.time < time.time(): 479 | # 若商品不存在则删除 480 | # 若重启时兑换超时则删除该兑换 481 | _ = list(user.exchange_plans) 482 | _.remove(plan) 483 | user.exchange_plans = set(_) 484 | PluginDataManager.write_plugin_data() 485 | continue 486 | else: 487 | finished.setdefault(plan, []) 488 | for i in range(plugin_config.preference.exchange_thread_count): 489 | scheduler.add_job( 490 | exchange_begin, 491 | "date", 492 | id=f"exchange-plan-{hash(plan)}-{i}", 493 | replace_existing=True, 494 | args=(plan,), 495 | run_date=datetime.fromtimestamp(good.time), 496 | max_instances=plugin_config.preference.exchange_thread_count 497 | ) 498 | 499 | 500 | def image_process(game: str, _lock: Lock = None): 501 | """ 502 | 生成并保存图片的进程函数 503 | 504 | :param game: 游戏名 505 | :param _lock: 进程锁 506 | :return: 生成成功或无商品返回True,否则返回False 507 | """ 508 | loop = asyncio.new_event_loop() 509 | good_list_status, good_list = loop.run_until_complete(get_good_list(game)) 510 | if not good_list_status: 511 | logger.error(f"{plugin_config.preference.log_head}获取 {game} 分区的商品列表失败,跳过该分区的商品图片生成") 512 | return False 513 | good_list = list(filter(lambda x: not x.time_end and x.time_limited, good_list)) 514 | if good_list: 515 | logger.info(f"{plugin_config.preference.log_head}正在生成 {game} 分区的商品列表图片") 516 | image_bytes = loop.run_until_complete(game_list_to_image(good_list, _lock)) 517 | if not image_bytes: 518 | return False 519 | date = time.strftime('%m-%d', time.localtime()) 520 | path = plugin_config.good_list_image_config.SAVE_PATH / f"{date}-{game}.jpg" 521 | with open(path, 'wb') as f: 522 | f.write(image_bytes) 523 | logger.info(f"{plugin_config.preference.log_head}已完成 {game} 分区的商品列表图片生成") 524 | else: 525 | logger.info(f"{plugin_config.preference.log_head}{game}分区暂时没有可兑换的限时商品,跳过该分区的商品图片生成") 526 | return True 527 | 528 | 529 | def generate_image(is_auto=True, callback: Callable[[bool], Any] = None): 530 | """ 531 | 生成米游币商品信息图片。该函数会阻塞当前线程 532 | 533 | :param is_auto: True为每日自动生成,False为用户手动更新 534 | :param callback: 回调函数,参数为生成成功与否 535 | """ 536 | for root, _, files in os.walk(plugin_config.good_list_image_config.SAVE_PATH, topdown=False): 537 | for name in files: 538 | date = time.strftime('%m-%d', time.localtime()) 539 | # 若图片开头为当日日期,则退出函数不执行 540 | if name.startswith(date): 541 | if is_auto: 542 | return 543 | # 删除旧图片 544 | if name.endswith('.jpg'): 545 | os.remove(os.path.join(root, name)) 546 | 547 | if plugin_config.good_list_image_config.MULTI_PROCESS: 548 | _lock: Lock = Manager().Lock() 549 | with Pool() as pool: 550 | for game in "bh3", "hk4e", "bh2", "hkrpg", "nxx", "bbs", "nap": 551 | pool.apply_async(image_process, 552 | args=(game, _lock), 553 | callback=callback) 554 | pool.close() 555 | pool.join() 556 | else: 557 | for game in "bh3", "hk4e", "bh2", "hkrpg", "nxx", "bbs", "nap": 558 | image_process(game) 559 | 560 | logger.info(f"{plugin_config.preference.log_head}已完成所有分区的商品列表图片生成") 561 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/command/help.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nonebot import on_command 4 | from nonebot.adapters.qq.exception import ActionFailed as QQGuildActionFailed 5 | from nonebot.internal.params import ArgStr 6 | from nonebot.matcher import Matcher 7 | from nonebot.params import CommandArg 8 | 9 | from ..command.common import CommandRegistry 10 | from ..model import plugin_config, CommandUsage 11 | from ..utils.common import PLUGIN, COMMAND_BEGIN, GeneralMessageEvent, logger, get_last_command_sep 12 | 13 | __all__ = ["helper"] 14 | 15 | helper = on_command( 16 | f"{plugin_config.preference.command_start}帮助", 17 | priority=1, 18 | aliases={f"{plugin_config.preference.command_start}help"}, 19 | block=True 20 | ) 21 | 22 | CommandRegistry.set_usage( 23 | helper, 24 | CommandUsage( 25 | name="帮助", 26 | description="🍺欢迎使用米游社小助手帮助系统!\n" 27 | "{HEAD}帮助 ➢ 查看米游社小助手使用说明\n" 28 | "{HEAD}帮助 <功能名> ➢ 查看目标功能详细说明" 29 | ) 30 | ) 31 | 32 | 33 | @helper.handle() 34 | async def _(_: Union[GeneralMessageEvent], matcher: Matcher, args=CommandArg()): 35 | """ 36 | 主命令触发 37 | """ 38 | # 二级命令 39 | if args: 40 | matcher.set_arg("content", args) 41 | # 只有主命令“帮助” 42 | else: 43 | try: 44 | await matcher.finish( 45 | f"{PLUGIN.metadata.name}" 46 | f"{PLUGIN.metadata.description}\n" 47 | "具体用法:\n" 48 | f"{PLUGIN.metadata.usage.format(HEAD=COMMAND_BEGIN)}" 49 | ) 50 | except QQGuildActionFailed as e: 51 | if e.code == 304003: 52 | logger.exception(f"{plugin_config.preference.log_head}帮助命令的文本发送失败,原因是频道禁止发送URL") 53 | 54 | 55 | @helper.got('content') 56 | async def _(_: Union[GeneralMessageEvent], content=ArgStr()): 57 | """ 58 | 二级命令触发。功能详细说明查询 59 | """ 60 | # 相似词 61 | if content == '登陆': 62 | content = '登录' 63 | 64 | matchers = PLUGIN.matcher 65 | for matcher in matchers: 66 | try: 67 | command_usage = CommandRegistry.get_usage(matcher) 68 | if command_usage and content.lower() == command_usage.name: 69 | description_text = command_usage.description or "" 70 | usage_text = f"\n\n{command_usage.usage}" if command_usage.usage else "" 71 | finish_text = f"『{COMMAND_BEGIN}{command_usage.name}』- 使用说明\n{description_text}{usage_text}" 72 | await helper.finish( 73 | finish_text.format( 74 | HEAD=COMMAND_BEGIN, 75 | SEP=get_last_command_sep() 76 | ) 77 | ) 78 | except AttributeError: 79 | continue 80 | await helper.finish("⚠️未查询到相关功能,请重新尝试") 81 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/command/login.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from typing import Union 4 | 5 | from nonebot import on_command 6 | from nonebot.adapters.onebot.v11 import MessageEvent as OneBotV11MessageEvent, MessageSegment as OneBotV11MessageSegment 7 | from nonebot.adapters.qq import MessageSegment as QQGuildMessageSegment, DirectMessageCreateEvent, \ 8 | MessageEvent as QQGuildMessageEvent 9 | from nonebot.adapters.qq.exception import AuditException 10 | from nonebot.exception import ActionFailed 11 | from nonebot.internal.matcher import Matcher 12 | from nonebot.internal.params import ArgStr 13 | from nonebot.params import T_State 14 | 15 | from ..api.common import get_ltoken_by_stoken, get_cookie_token_by_stoken, get_device_fp, fetch_game_token_qrcode, \ 16 | query_game_token_qrcode, \ 17 | get_token_by_game_token, get_cookie_token_by_game_token 18 | from ..command.common import CommandRegistry 19 | from ..model import PluginDataManager, plugin_config, UserAccount, UserData, CommandUsage, BBSCookies, \ 20 | QueryGameTokenQrCodeStatus, GetCookieStatus 21 | from ..utils import logger, COMMAND_BEGIN, GeneralMessageEvent, GeneralPrivateMessageEvent, \ 22 | GeneralGroupMessageEvent, \ 23 | read_blacklist, read_whitelist, generate_device_id, generate_qr_img 24 | 25 | __all__ = ["get_cookie", "output_cookies"] 26 | 27 | get_cookie = on_command(plugin_config.preference.command_start + '登录', priority=4, block=True) 28 | 29 | CommandRegistry.set_usage( 30 | get_cookie, 31 | CommandUsage( 32 | name="登录", 33 | description="跟随指引,通过电话获取短信方式绑定米游社账户,配置完成后会自动开启签到、米游币任务,后续可制定米游币自动兑换计划。" 34 | ) 35 | ) 36 | 37 | 38 | @get_cookie.handle() 39 | async def handle_first_receive(event: Union[GeneralMessageEvent]): 40 | user_num = len(set(PluginDataManager.plugin_data.users.values())) # 由于加入了用户数据绑定功能,可能存在重复的用户数据对象,需要去重 41 | if plugin_config.preference.enable_blacklist: 42 | if event.get_user_id() in read_blacklist(): 43 | await get_cookie.finish("⚠️您已被加入黑名单,无法使用本功能") 44 | elif plugin_config.preference.enable_whitelist: 45 | if event.get_user_id() not in read_whitelist(): 46 | await get_cookie.finish("⚠️您不在白名单内,无法使用本功能") 47 | if user_num <= plugin_config.preference.max_user or plugin_config.preference.max_user in [-1, 0]: 48 | # 获取用户数据对象 49 | user_id = event.get_user_id() 50 | PluginDataManager.plugin_data.users.setdefault(user_id, UserData()) 51 | user = PluginDataManager.plugin_data.users[user_id] 52 | # 如果是QQ频道,需要记录频道ID 53 | if isinstance(event, DirectMessageCreateEvent): 54 | user.qq_guild[user_id] = event.channel_id 55 | 56 | # 1. 获取 GameToken 登录二维码 57 | device_id = generate_device_id() 58 | login_status, fetch_qrcode_ret = await fetch_game_token_qrcode( 59 | device_id, 60 | plugin_config.preference.game_token_app_id 61 | ) 62 | if fetch_qrcode_ret: 63 | qrcode_url, qrcode_ticket = fetch_qrcode_ret 64 | await get_cookie.send("请用米游社App扫描下面的二维码进行登录") 65 | image_bytes = generate_qr_img(qrcode_url) 66 | if isinstance(event, OneBotV11MessageEvent): 67 | msg_img = OneBotV11MessageSegment.image(image_bytes) 68 | elif isinstance(event, QQGuildMessageEvent): 69 | msg_img = QQGuildMessageSegment.file_image(image_bytes) 70 | else: 71 | await get_cookie.finish("⚠️发送二维码失败,无法登录") 72 | try: 73 | await get_cookie.send(msg_img) 74 | except (ActionFailed, AuditException) as e: 75 | if isinstance(e, ActionFailed): 76 | logger.exception("发送包含二维码的登录消息失败") 77 | await get_cookie.finish("⚠️发送二维码失败,无法登录") 78 | 79 | # 2. 从二维码登录获取 GameToken 80 | qrcode_query_times = round( 81 | plugin_config.preference.qrcode_wait_time / plugin_config.preference.qrcode_query_interval 82 | ) 83 | bbs_uid, game_token = None, None 84 | for _ in range(qrcode_query_times): 85 | login_status, query_qrcode_ret = await query_game_token_qrcode( 86 | qrcode_ticket, 87 | device_id, 88 | plugin_config.preference.game_token_app_id 89 | ) 90 | if query_qrcode_ret: 91 | bbs_uid, game_token = query_qrcode_ret 92 | logger.success(f"用户 {bbs_uid} 成功获取 game_token: {game_token}") 93 | break 94 | elif login_status.qrcode_expired: 95 | await get_cookie.finish("⚠️二维码已过期,登录失败") 96 | elif not login_status: 97 | await asyncio.sleep(plugin_config.preference.qrcode_query_interval) 98 | continue 99 | 100 | if bbs_uid and game_token: 101 | cookies = BBSCookies() 102 | cookies.bbs_uid = bbs_uid 103 | account = PluginDataManager.plugin_data.users[user_id].accounts.get(bbs_uid) 104 | """当前的账户数据对象""" 105 | if not account or not account.cookies: 106 | user.accounts.update({ 107 | bbs_uid: UserAccount( 108 | phone_number=None, 109 | cookies=cookies, 110 | device_id_ios=device_id, 111 | device_id_android=generate_device_id()) 112 | }) 113 | account = user.accounts[bbs_uid] 114 | else: 115 | account.cookies.update(cookies) 116 | fp_status, account.device_fp = await get_device_fp(device_id) 117 | if fp_status: 118 | logger.success(f"用户 {bbs_uid} 成功获取 device_fp: {account.device_fp}") 119 | PluginDataManager.write_plugin_data() 120 | 121 | if login_status: 122 | # 3. 通过 GameToken 获取 stoken_v2 123 | login_status, cookies = await get_token_by_game_token(bbs_uid, game_token) 124 | if login_status: 125 | logger.success(f"用户 {bbs_uid} 成功获取 stoken_v2: {cookies.stoken_v2}") 126 | account.cookies.update(cookies) 127 | PluginDataManager.write_plugin_data() 128 | 129 | if account.cookies.stoken_v2: 130 | # 5. 通过 stoken_v2 获取 ltoken 131 | login_status, cookies = await get_ltoken_by_stoken(account.cookies, device_id) 132 | if login_status: 133 | logger.success(f"用户 {bbs_uid} 成功获取 ltoken: {cookies.ltoken}") 134 | account.cookies.update(cookies) 135 | PluginDataManager.write_plugin_data() 136 | 137 | # 6.1. 通过 stoken_v2 获取 cookie_token 138 | login_status, cookies = await get_cookie_token_by_stoken(account.cookies, device_id) 139 | if login_status: 140 | logger.success(f"用户 {bbs_uid} 成功获取 cookie_token: {cookies.cookie_token}") 141 | account.cookies.update(cookies) 142 | PluginDataManager.write_plugin_data() 143 | 144 | logger.success( 145 | f"{plugin_config.preference.log_head}米游社账户 {bbs_uid} 绑定成功") 146 | await get_cookie.finish(f"🎉米游社账户 {bbs_uid} 绑定成功") 147 | else: 148 | # 6.2. 通过 GameToken 获取 cookie_token 149 | login_status, cookies = await get_cookie_token_by_game_token(bbs_uid, game_token) 150 | if login_status: 151 | logger.success(f"用户 {bbs_uid} 成功获取 cookie_token: {cookies.cookie_token}") 152 | account.cookies.update(cookies) 153 | PluginDataManager.write_plugin_data() 154 | else: 155 | await get_cookie.finish("⚠️获取二维码扫描状态超时,请尝试重新登录") 156 | 157 | if not login_status: 158 | notice_text = "⚠️登录失败:" 159 | if isinstance(login_status, QueryGameTokenQrCodeStatus): 160 | if login_status.qrcode_expired: 161 | notice_text += "登录二维码已过期!" 162 | if isinstance(login_status, GetCookieStatus): 163 | if login_status.missing_bbs_uid: 164 | notice_text += "Cookies缺少 bbs_uid(例如 ltuid, stuid)" 165 | elif login_status.missing_login_ticket: 166 | notice_text += "Cookies缺少 login_ticket!" 167 | elif login_status.missing_cookie_token: 168 | notice_text += "Cookies缺少 cookie_token!" 169 | elif login_status.missing_stoken: 170 | notice_text += "Cookies缺少 stoken!" 171 | elif login_status.missing_stoken_v1: 172 | notice_text += "Cookies缺少 stoken_v1" 173 | elif login_status.missing_stoken_v2: 174 | notice_text += "Cookies缺少 stoken_v2" 175 | elif login_status.missing_mid: 176 | notice_text += "Cookies缺少 mid" 177 | if login_status.login_expired: 178 | notice_text += "登录失效!" 179 | elif login_status.incorrect_return: 180 | notice_text += "服务器返回错误!" 181 | elif login_status.network_error: 182 | notice_text += "网络连接失败!" 183 | else: 184 | notice_text += "未知错误!" 185 | notice_text += " 如果部分步骤成功,你仍然可以尝试获取收货地址、兑换等功能" 186 | await get_cookie.finish(notice_text) 187 | 188 | else: 189 | await get_cookie.finish('⚠️目前可支持使用用户数已经满啦~') 190 | 191 | 192 | output_cookies = on_command( 193 | plugin_config.preference.command_start + '导出Cookies', 194 | aliases={plugin_config.preference.command_start + '导出Cookie', plugin_config.preference.command_start + '导出账号', 195 | plugin_config.preference.command_start + '导出cookie', 196 | plugin_config.preference.command_start + '导出cookies'}, priority=4, 197 | block=True) 198 | 199 | CommandRegistry.set_usage( 200 | output_cookies, 201 | CommandUsage( 202 | name="导出Cookies", 203 | description="导出绑定的米游社账号的Cookies数据" 204 | ) 205 | ) 206 | 207 | 208 | @output_cookies.handle() 209 | async def handle_first_receive(event: Union[GeneralMessageEvent], state: T_State): 210 | """ 211 | Cookies导出命令触发 212 | """ 213 | if isinstance(event, GeneralGroupMessageEvent): 214 | await output_cookies.finish("⚠️为了保护您的隐私,请私聊进行Cookies导出。") 215 | user_account = PluginDataManager.plugin_data.users[event.get_user_id()].accounts 216 | if not user_account: 217 | await output_cookies.finish(f"⚠️你尚未绑定米游社账户,请先使用『{COMMAND_BEGIN}登录』进行登录") 218 | elif len(user_account) == 1: 219 | account = next(iter(user_account.values())) 220 | state["bbs_uid"] = account.bbs_uid 221 | else: 222 | msg = "您有多个账号,您要导出哪个账号的Cookies数据?\n" 223 | msg += "\n".join(map(lambda x: f"🆔{x}", user_account)) 224 | msg += "\n🚪发送“退出”即可退出" 225 | await output_cookies.send(msg) 226 | 227 | 228 | @output_cookies.got('bbs_uid') 229 | async def _(event: Union[GeneralPrivateMessageEvent], matcher: Matcher, bbs_uid=ArgStr()): 230 | """ 231 | 根据手机号设置导出相应的账户的Cookies 232 | """ 233 | if bbs_uid == '退出': 234 | await matcher.finish('🚪已成功退出') 235 | user_account = PluginDataManager.plugin_data.users[event.get_user_id()].accounts 236 | if bbs_uid in user_account: 237 | await output_cookies.finish(json.dumps(user_account[bbs_uid].cookies.dict(cookie_type=True), indent=4)) 238 | else: 239 | await matcher.reject('⚠️您输入的账号不在以上账号内,请重新输入') 240 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/command/setting.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nonebot import on_command 4 | from nonebot.internal.params import ArgStr 5 | from nonebot.matcher import Matcher 6 | from nonebot.params import T_State 7 | 8 | from ..api import BaseMission, BaseGameSign 9 | from ..command.common import CommandRegistry 10 | from ..model import PluginDataManager, plugin_config, UserAccount, CommandUsage 11 | from ..utils import COMMAND_BEGIN, GeneralMessageEvent 12 | 13 | __all__ = ["setting", "account_setting", "global_setting"] 14 | 15 | setting = on_command(plugin_config.preference.command_start + '设置', priority=4, block=True) 16 | 17 | CommandRegistry.set_usage( 18 | setting, 19 | CommandUsage( 20 | name="设置", 21 | description="如需配置是否开启每日任务、设备平台、频道任务等相关选项,请使用『{HEAD}账号设置』命令。\n" 22 | "如需设置米游币任务和游戏签到后是否进行QQ通知,请使用『{HEAD}通知设置』命令。" 23 | ) 24 | ) 25 | 26 | 27 | @setting.handle() 28 | async def _(_: Union[GeneralMessageEvent]): 29 | msg = f'如需配置是否开启每日任务、设备平台、频道任务等相关选项,请使用『{COMMAND_BEGIN}账号设置』命令' \ 30 | f'\n如需设置米游币任务和游戏签到后是否进行QQ通知,请使用『{COMMAND_BEGIN}通知设置』命令' 31 | await setting.send(msg) 32 | 33 | 34 | account_setting = on_command(plugin_config.preference.command_start + '账号设置', priority=5, block=True) 35 | 36 | CommandRegistry.set_usage( 37 | account_setting, 38 | CommandUsage( 39 | name="账号设置", 40 | description="配置游戏自动签到、米游币任务是否开启、设备平台、频道任务相关选项" 41 | ) 42 | ) 43 | 44 | 45 | @account_setting.handle() 46 | async def _(event: Union[GeneralMessageEvent], matcher: Matcher, state: T_State): 47 | """ 48 | 账号设置命令触发 49 | """ 50 | user = PluginDataManager.plugin_data.users.get(event.get_user_id()) 51 | user_account = user.accounts if user else None 52 | if not user_account: 53 | await account_setting.finish( 54 | f"⚠️你尚未绑定米游社账户,请先使用『{plugin_config.preference.command_start}登录』进行登录") 55 | if len(user_account) == 1: 56 | uid = next(iter(user_account.values())).bbs_uid 57 | state["bbs_uid"] = uid 58 | else: 59 | msg = "您有多个账号,您要更改以下哪个账号的设置?\n" 60 | msg += "\n".join(map(lambda x: f"🆔{x.display_name}", user_account.values())) 61 | msg += "\n🚪发送“退出”即可退出" 62 | await matcher.send(msg) 63 | 64 | 65 | @account_setting.got('bbs_uid') 66 | async def _(event: Union[GeneralMessageEvent], matcher: Matcher, state: T_State, bbs_uid=ArgStr()): 67 | """ 68 | 根据手机号设置相应的账户 69 | """ 70 | if bbs_uid == '退出': 71 | await matcher.finish('🚪已成功退出') 72 | 73 | user_account = PluginDataManager.plugin_data.users[event.get_user_id()].accounts 74 | if not (account := user_account.get(bbs_uid)): 75 | await account_setting.reject('⚠️您发送的账号不在以上账号内,请重新发送') 76 | state["user"] = PluginDataManager.plugin_data.users[event.get_user_id()] 77 | state['account'] = account 78 | state["prepare_to_delete"] = False 79 | 80 | user_setting = "" 81 | user_setting += f"1️⃣ 米游币任务自动执行:{'开' if account.enable_mission else '关'}" 82 | user_setting += f"\n2️⃣ 游戏自动签到:{'开' if account.enable_game_sign else '关'}" 83 | 84 | # 筛选出用户数据中的missionGame对应的游戏全称 85 | user_setting += "\n\n3️⃣ 执行签到的游戏:" + \ 86 | "\n- " + "、".join( 87 | f"『{next((game.name for game in BaseGameSign.available_game_signs if game.en_name == game_id), 'N/A')}』" 88 | for game_id in account.game_sign_games 89 | ) 90 | 91 | platform_show = "iOS" if account.platform == "ios" else "安卓" 92 | user_setting += f"\n4️⃣ 设备平台:{platform_show}" 93 | 94 | # 筛选出用户数据中的missionGame对应的游戏全称 95 | user_setting += "\n\n5️⃣ 执行米游币任务的频道:" + \ 96 | "\n- " + "、".join( 97 | map( 98 | lambda x: f"『{x.name}』" if x else "『N/A』", 99 | map( 100 | BaseMission.available_games.get, 101 | account.mission_games 102 | ) 103 | ) 104 | ) 105 | 106 | user_setting += f"\n\n6️⃣ 实时便笺体力提醒:{'开' if account.enable_resin else '关'}" 107 | user_setting += f"\n7️⃣更改便笺体力提醒阈值 \ 108 | \n 当前原神提醒阈值:{account.user_resin_threshold} \ 109 | \n 当前崩铁提醒阈值:{account.user_stamina_threshold}" 110 | user_setting += "\n8️⃣⚠️删除账户数据" 111 | 112 | await account_setting.send(user_setting + '\n\n您要更改哪一项呢?请发送 1 / 2 / 3 / 4 / 5 / 6 / 7/ 8' 113 | '\n🚪发送“退出”即可退出') 114 | 115 | 116 | @account_setting.got('setting_id') 117 | async def _(event: Union[GeneralMessageEvent], state: T_State, setting_id=ArgStr()): 118 | """ 119 | 根据所选更改相应账户的相应设置 120 | """ 121 | account: UserAccount = state['account'] 122 | user_account = PluginDataManager.plugin_data.users[event.get_user_id()].accounts 123 | if setting_id == '退出': 124 | await account_setting.finish('🚪已成功退出') 125 | elif setting_id == '1': 126 | account.enable_mission = not account.enable_mission 127 | PluginDataManager.write_plugin_data() 128 | await account_setting.finish(f"📅米游币任务自动执行已 {'✅开启' if account.enable_mission else '❌关闭'}") 129 | elif setting_id == '2': 130 | account.enable_game_sign = not account.enable_game_sign 131 | PluginDataManager.write_plugin_data() 132 | await account_setting.finish(f"📅米哈游游戏自动签到已 {'✅开启' if account.enable_game_sign else '❌关闭'}") 133 | elif setting_id == '3': 134 | signable_games = "、".join(f"『{game.name}』" for game in BaseGameSign.available_game_signs) 135 | await account_setting.send( 136 | "请发送你想要执行签到的游戏:" 137 | "\n❕多个游戏请用空格分隔,如 “原神 崩坏3 综合”" 138 | "\n\n可选的游戏:" 139 | f"\n- {signable_games}" 140 | "\n\n🚪发送“退出”即可退出" 141 | ) 142 | state["setting_item"] = "sign_games" 143 | elif setting_id == '4': 144 | if account.platform == "ios": 145 | account.platform = "android" 146 | platform_show = "安卓" 147 | else: 148 | account.platform = "ios" 149 | platform_show = "iOS" 150 | PluginDataManager.write_plugin_data() 151 | await account_setting.finish(f"📲设备平台已更改为 {platform_show}") 152 | elif setting_id == '5': 153 | games_show = "、".join(map(lambda x: f"『{x.name}』", BaseMission.available_games.values())) 154 | await account_setting.send( 155 | "请发送你想要执行米游币任务的频道:" 156 | "\n❕多个频道请用空格分隔,如 “原神 崩坏3 综合”" 157 | "\n\n可选的游戏:" 158 | f"\n- {games_show}" 159 | "\n\n🚪发送“退出”即可退出" 160 | ) 161 | state["setting_item"] = "mission_games" 162 | elif setting_id == '6': 163 | account.enable_resin = not account.enable_resin 164 | PluginDataManager.write_plugin_data() 165 | await account_setting.finish(f"📅原神、星穹铁道便笺提醒已 {'✅开启' if account.enable_resin else '❌关闭'}") 166 | elif setting_id == '7': 167 | await account_setting.send( 168 | "请发送想要修改体力提醒阈值的游戏编号:" 169 | "\n1. 原神" 170 | "\n2. 崩坏:星穹铁道" 171 | "\n\n🚪发送“退出”即可退出" 172 | ) 173 | state["setting_item"] = "setting_notice_value" 174 | return 175 | elif setting_id == '8': 176 | state["prepare_to_delete"] = True 177 | await account_setting.reject(f"⚠️确认删除账号 {account.display_name} ?发送 \"确认删除\" 以确定。") 178 | elif setting_id == '确认删除' and state["prepare_to_delete"]: 179 | user_account.pop(account.bbs_uid) 180 | PluginDataManager.write_plugin_data() 181 | await account_setting.finish(f"已删除账号 {account.display_name} 的数据") 182 | else: 183 | await account_setting.reject("⚠️您的输入有误,请重新输入") 184 | state["notice_game"] = "" 185 | 186 | 187 | @account_setting.got('notice_game') 188 | async def _(_: Union[GeneralMessageEvent], state: T_State, notice_game=ArgStr()): 189 | if notice_game == '退出': 190 | await account_setting.finish('🚪已成功退出') 191 | elif state["setting_item"] == "setting_notice_value": 192 | if notice_game == "1": 193 | await account_setting.send( 194 | "请输入想要所需通知阈值,树脂达到该值时将进行通知:" 195 | "可用范围 [0, 200]" 196 | "\n\n🚪发送“退出”即可退出" 197 | ) 198 | state["setting_item"] = "setting_notice_value_op" 199 | elif notice_game == "2": 200 | await account_setting.send( 201 | "请输入想要所需阈值数字,开拓力达到该值时将进行通知:" 202 | "可用范围 [0, 240]" 203 | "\n\n🚪发送“退出”即可退出" 204 | ) 205 | state["setting_item"] = "setting_notice_value_sr" 206 | else: 207 | await account_setting.reject("⚠️您的输入有误,请重新输入") 208 | 209 | 210 | @account_setting.got('setting_value') 211 | async def _(_: Union[GeneralMessageEvent], state: T_State, setting_value=ArgStr()): 212 | if setting_value == '退出': 213 | await account_setting.finish('🚪已成功退出') 214 | account: UserAccount = state['account'] 215 | 216 | if state["setting_item"] == "setting_notice_value_op": 217 | try: 218 | resin_threshold = int(setting_value) 219 | except ValueError: 220 | await account_setting.reject("⚠️请输入有效的数字。") 221 | else: 222 | if 0 <= resin_threshold <= 200: 223 | # 输入有效的数字范围,将 resin_threshold 赋值为输入的整数 224 | account.user_resin_threshold = resin_threshold 225 | PluginDataManager.write_plugin_data() 226 | await account_setting.finish("更改原神便笺树脂提醒阈值成功\n" 227 | f"⏰当前提醒阈值:{resin_threshold}") 228 | else: 229 | await account_setting.reject("⚠️输入的数字范围应在 0 到 200 之间。") 230 | 231 | elif state["setting_item"] == "setting_notice_value_sr": 232 | try: 233 | stamina_threshold = int(setting_value) 234 | except ValueError: 235 | await account_setting.reject("⚠️请输入有效的数字。") 236 | else: 237 | if 0 <= stamina_threshold <= 240: 238 | # 输入有效的数字范围,将 stamina_threshold 赋值为输入的整数 239 | account.user_stamina_threshold = stamina_threshold 240 | PluginDataManager.write_plugin_data() 241 | await account_setting.finish("更改崩铁便笺开拓力提醒阈值成功\n" 242 | f"⏰当前提醒阈值:{stamina_threshold}") 243 | else: 244 | await account_setting.reject("⚠️输入的数字范围应在 0 到 240 之间。") 245 | 246 | elif state["setting_item"] == "sign_games": 247 | games_input = setting_value.split() 248 | sign_games = [] 249 | for game in games_input: 250 | subclass_filter = filter(lambda x: x.name == game, BaseGameSign.available_game_signs) 251 | subclass_pair = next(subclass_filter, None) 252 | if subclass_pair is None: 253 | await account_setting.reject("⚠️您的输入有误,请重新输入") 254 | else: 255 | game_name = subclass_pair.en_name 256 | sign_games.append(game_name) 257 | 258 | account.game_sign_games = sign_games 259 | PluginDataManager.write_plugin_data() 260 | setting_value = setting_value.replace(" ", "、") 261 | await account_setting.finish(f"💬执行签到的游戏已更改为『{setting_value}』") 262 | 263 | elif state["setting_item"] == "mission_games": 264 | games_input = setting_value.split() 265 | mission_games = [] 266 | for game in games_input: 267 | subclass_filter = filter(lambda x: x[1].name == game, BaseMission.available_games.items()) 268 | subclass_pair = next(subclass_filter, None) 269 | if subclass_pair is None: 270 | await account_setting.reject("⚠️您的输入有误,请重新输入") 271 | else: 272 | game_name, _ = subclass_pair 273 | mission_games.append(game_name) 274 | 275 | account.mission_games = mission_games 276 | PluginDataManager.write_plugin_data() 277 | setting_value = setting_value.replace(" ", "、") 278 | await account_setting.finish(f"💬执行米游币任务的频道已更改为『{setting_value}』") 279 | 280 | 281 | global_setting = on_command(plugin_config.preference.command_start + '通知设置', priority=5, block=True) 282 | 283 | CommandRegistry.set_usage( 284 | global_setting, 285 | CommandUsage( 286 | name="通知设置", 287 | description="设置每日签到后是否进行QQ通知" 288 | ) 289 | ) 290 | 291 | 292 | @global_setting.handle() 293 | async def _(event: Union[GeneralMessageEvent], matcher: Matcher): 294 | """ 295 | 通知设置命令触发 296 | """ 297 | user = PluginDataManager.plugin_data.users[event.get_user_id()] 298 | await matcher.send( 299 | f"自动通知每日计划任务结果:{'🔔开' if user.enable_notice else '🔕关'}" 300 | "\n请问您是否需要更改呢?\n请回复“是”或“否”\n🚪发送“退出”即可退出") 301 | 302 | 303 | @global_setting.got('choice') 304 | async def _(event: Union[GeneralMessageEvent], matcher: Matcher, choice=ArgStr()): 305 | """ 306 | 根据选择变更通知设置 307 | """ 308 | user = PluginDataManager.plugin_data.users[event.get_user_id()] 309 | if choice == '退出': 310 | await matcher.finish("🚪已成功退出") 311 | elif choice == '是': 312 | user.enable_notice = not user.enable_notice 313 | PluginDataManager.write_plugin_data() 314 | await matcher.finish(f"自动通知每日计划任务结果 已 {'🔔开启' if user.enable_notice else '🔕关闭'}") 315 | elif choice == '否': 316 | await matcher.finish("没有做修改哦~") 317 | else: 318 | await matcher.reject("⚠️您的输入有误,请重新输入") 319 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/command/user_check.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Union 3 | from uuid import uuid4 4 | 5 | from nonebot import get_driver, on_request, on_command, Bot 6 | from nonebot.adapters.onebot.v11 import FriendRequestEvent, GroupRequestEvent, RequestEvent, Bot as OneBotV11Bot 7 | from nonebot.adapters.qq import Bot as QQGuildBot, DirectMessageCreateEvent, MessageCreateEvent 8 | from nonebot.adapters.qq.exception import ActionFailed as QQGuildActionFailed, AuditException 9 | from nonebot.internal.matcher import Matcher 10 | from nonebot.params import CommandArg, Command 11 | 12 | from ..command.common import CommandRegistry 13 | from ..model import PluginDataManager, plugin_config, uuid4_validate, CommandUsage 14 | from ..utils import logger, GeneralMessageEvent, COMMAND_BEGIN, get_last_command_sep, \ 15 | GeneralGroupMessageEvent, PLUGIN, \ 16 | send_private_msg 17 | 18 | __all__ = ["friendRequest", "user_binding", "direct_msg_respond"] 19 | _driver = get_driver() 20 | 21 | 22 | @_driver.on_bot_connect 23 | def check_qq_config(bot: QQGuildBot): 24 | """ 25 | 检查QQGuild适配器是否开启了私信功能 Intents.direct_message 26 | 27 | :param bot: QQGuild的Bot对象 28 | """ 29 | if isinstance(bot, QQGuildBot): 30 | if not bot.bot_info.intent.direct_message: 31 | logger.warning( 32 | f'{plugin_config.preference.log_head}QQGuild适配器未开启私信功能 Intents.direct_message,将无法响应私信消息') 33 | 34 | 35 | friendRequest = on_request(priority=1, block=True) 36 | 37 | 38 | @friendRequest.handle() 39 | async def _(bot: OneBotV11Bot, event: RequestEvent): 40 | command_start = list(get_driver().config.command_start)[0] 41 | # 判断为加好友事件 42 | if isinstance(event, FriendRequestEvent): 43 | if plugin_config.preference.add_friend_accept: 44 | logger.info(f'{plugin_config.preference.log_head}已添加好友{event.user_id}') 45 | await bot.set_friend_add_request(flag=event.flag, approve=True) 46 | if plugin_config.preference.add_friend_welcome: 47 | # 等待腾讯服务器响应 48 | await asyncio.sleep(1.5) 49 | await bot.send_private_msg(user_id=event.user_id, 50 | message=f'欢迎使用米游社小助手,请发送『{command_start}帮助』查看更多用法哦~') 51 | # 判断为邀请进群事件 52 | elif isinstance(event, GroupRequestEvent): 53 | logger.info(f'{plugin_config.preference.log_head}已加入群聊 {event.group_id}') 54 | 55 | 56 | user_binding = on_command( 57 | f"{plugin_config.preference.command_start}用户绑定", 58 | aliases={ 59 | (f"{plugin_config.preference.command_start}用户绑定", "UUID"), 60 | (f"{plugin_config.preference.command_start}用户绑定", "uuid"), 61 | (f"{plugin_config.preference.command_start}用户绑定", "查询"), 62 | (f"{plugin_config.preference.command_start}用户绑定", "还原"), 63 | (f"{plugin_config.preference.command_start}用户绑定", "刷新UUID"), 64 | (f"{plugin_config.preference.command_start}用户绑定", "刷新uuid") 65 | }, 66 | priority=5, 67 | block=True 68 | ) 69 | 70 | CommandRegistry.set_usage( 71 | user_binding, 72 | CommandUsage( 73 | name="用户绑定", 74 | description="通过UUID绑定关联其他聊天平台或者其他账号的用户数据,以免去重新登录等操作", 75 | usage="具体用法:\n" 76 | "🔑 {HEAD}用户绑定{SEP}UUID ➢ 查看用于绑定的当前用户数据的UUID密钥\n" 77 | "🔍 {HEAD}用户绑定{SEP}查询 ➢ 查看当前用户的绑定情况\n" 78 | "↩️ {HEAD}用户绑定{SEP}还原 ➢ 清除当前用户的绑定关系,使当前用户数据成为空白数据\n" 79 | "🔄️ {HEAD}用户绑定{SEP}刷新UUID ➢ 重新生成当前用户的UUID密钥,同时原先与您绑定的用户将无法访问您当前的用户数据\n" 80 | "🖇️ {HEAD}用户绑定 ➢ 绑定目标UUID的用户数据,当前用户的所有数据将被目标用户覆盖\n" 81 | "『{SEP}』为分隔符,使用NoneBot配置中的其他分隔符亦可" 82 | ) 83 | ) 84 | user_binding_usage = CommandRegistry.get_usage(user_binding) 85 | 86 | 87 | @user_binding.handle() 88 | async def _( 89 | event: Union[GeneralMessageEvent], 90 | matcher: Matcher, 91 | command=Command(), 92 | command_arg=CommandArg() 93 | ): 94 | user_id = event.get_user_id() 95 | user = PluginDataManager.plugin_data.users.get(user_id) 96 | if len(command) > 1: 97 | if user is None: 98 | await matcher.finish("⚠️您的用户数据不存在,只有进行登录操作以后才会生成用户数据") 99 | elif command[1] in ["UUID", "uuid"]: 100 | if isinstance(event, GeneralGroupMessageEvent): 101 | await matcher.finish("⚠️为了保护您的隐私,请私聊进行UUID密钥查看。") 102 | 103 | await matcher.send( 104 | f"{'🔑您的UUID密钥为:' if user_id not in PluginDataManager.plugin_data.user_bind else '🔑您绑定的用户数据的UUID密钥为:'}\n" 105 | f"{user.uuid.upper()}\n" 106 | "可用于其他聊天平台进行数据绑定,请不要泄露给他人" 107 | ) 108 | 109 | elif command[1] == "查询": 110 | if user_id in PluginDataManager.plugin_data.user_bind: 111 | await matcher.send( 112 | "🖇️目前您绑定关联了用户:\n" 113 | f"{PluginDataManager.plugin_data.user_bind[user_id]}\n" 114 | "您的任何操作都将会影响到目标用户的数据" 115 | ) 116 | elif user_id in PluginDataManager.plugin_data.user_bind.values(): 117 | user_filter = filter(lambda x: PluginDataManager.plugin_data.user_bind[x] == user_id, 118 | PluginDataManager.plugin_data.user_bind) 119 | await matcher.send( 120 | "🖇️目前有以下用户绑定了您的数据:\n" 121 | "\n".join(user_filter) 122 | ) 123 | else: 124 | await matcher.send("⚠️您当前没有绑定任何用户数据,也没有任何用户绑定您的数据") 125 | 126 | elif command[1] == "还原": 127 | if user_id not in PluginDataManager.plugin_data.user_bind: 128 | await matcher.finish("⚠️您当前没有绑定任何用户数据") 129 | else: 130 | del PluginDataManager.plugin_data.user_bind[user_id] 131 | del PluginDataManager.plugin_data.users[user_id] 132 | PluginDataManager.write_plugin_data() 133 | await matcher.send("✔已清除当前用户的绑定关系,当前用户数据已是空白数据") 134 | 135 | elif command[1] in ["刷新UUID", "刷新uuid"]: 136 | if isinstance(event, GeneralGroupMessageEvent): 137 | await matcher.finish("⚠️为了保护您的隐私,请私聊进行UUID密钥刷新。") 138 | 139 | if user_id in PluginDataManager.plugin_data.user_bind: 140 | target_id = PluginDataManager.plugin_data.user_bind[user_id] 141 | be_bind = False 142 | else: 143 | target_id = user_id 144 | be_bind = True 145 | 146 | src_users = list(filter(lambda x: PluginDataManager.plugin_data.user_bind[x] == target_id, 147 | PluginDataManager.plugin_data.user_bind)) 148 | for key in src_users: 149 | del PluginDataManager.plugin_data.user_bind[key] 150 | del PluginDataManager.plugin_data.users[key] 151 | PluginDataManager.plugin_data.users[target_id].uuid = str(uuid4()) 152 | PluginDataManager.write_plugin_data() 153 | 154 | await matcher.send( 155 | f"{'✔已刷新UUID密钥,原先绑定的用户将无法访问当前用户数据' if be_bind else '✔已刷新您绑定的用户数据的UUID密钥,目前您的用户数据已为空,您也可以再次绑定'}\n" 156 | f"🔑新的UUID密钥:{user.uuid.upper()}\n" 157 | "可用于其他聊天平台进行数据绑定,请不要泄露给他人" 158 | ) 159 | else: 160 | await matcher.reject( 161 | '⚠️您的输入有误,二级命令不正确\n\n' 162 | f"{user_binding_usage.usage.format(HEAD=COMMAND_BEGIN, SEP=get_last_command_sep())}" 163 | ) 164 | elif not command_arg: 165 | await matcher.send( 166 | f"『{COMMAND_BEGIN}{user_binding_usage.name}』- 使用说明\n" 167 | f"{user_binding_usage.description.format(HEAD=COMMAND_BEGIN)}\n" 168 | f"{user_binding_usage.usage.format(HEAD=COMMAND_BEGIN, SEP=get_last_command_sep())}" 169 | ) 170 | else: 171 | uuid = str(command_arg).lower() 172 | if not uuid4_validate(uuid): 173 | await matcher.finish("⚠️您输入的UUID密钥格式不正确") 174 | elif user and uuid == user.uuid: 175 | await matcher.finish("⚠️您不能绑定自己的UUID密钥") 176 | else: 177 | # 筛选UUID密钥对应的用户 178 | target_users = list( 179 | filter(lambda x: x[1].uuid == uuid and x[0] != user_id, PluginDataManager.plugin_data.users.items())) 180 | # 如果有多个用户使用了此UUID密钥,即目标用户被多个用户绑定,需要进一步筛选,防止形成循环绑定的关系链 181 | if len(target_users) > 1: 182 | user_filter = filter(lambda x: x[0] not in PluginDataManager.plugin_data.user_bind, target_users) 183 | target_id, _ = next(user_filter) 184 | elif len(target_users) == 1: 185 | target_id, _ = target_users[0] 186 | else: 187 | await matcher.finish("⚠️找不到此UUID密钥对应的用户数据") 188 | PluginDataManager.plugin_data.do_user_bind(user_id, target_id) 189 | user = PluginDataManager.plugin_data.users[user_id] 190 | if isinstance(event, DirectMessageCreateEvent): 191 | user.qq_guild[user_id] = event.channel_id 192 | elif isinstance(event, MessageCreateEvent): 193 | user.qq_guild[user_id] = event.guild_id 194 | if isinstance(event, GeneralGroupMessageEvent): 195 | user.uuid = str(uuid4()) 196 | await matcher.send("🔑由于您在群聊中进行绑定,已刷新您的UUID密钥,但不会影响其他已绑定用户") 197 | PluginDataManager.write_plugin_data() 198 | await matcher.send(f"✔已绑定用户 {target_id} 的用户数据") 199 | 200 | 201 | direct_msg_respond = on_command( 202 | f"{plugin_config.preference.command_start}私信响应", 203 | aliases={ 204 | f"{plugin_config.preference.command_start}私聊响应", 205 | f"{plugin_config.preference.command_start}请求响应" 206 | }, 207 | priority=5, 208 | block=True 209 | ) 210 | 211 | CommandRegistry.set_usage( 212 | direct_msg_respond, 213 | CommandUsage( 214 | name="私信响应", 215 | description="让机器人私信发送给您一条消息,防止因为发送了三条私信消息而机器人未回复导致无法继续私信。" 216 | ) 217 | ) 218 | 219 | 220 | @direct_msg_respond.handle() 221 | async def _(bot: Bot, event: Union[GeneralGroupMessageEvent]): 222 | # 附加功能:记录用户所在频道 223 | if isinstance(event, MessageCreateEvent): 224 | user_id = event.get_user_id() 225 | if user := PluginDataManager.plugin_data.users.get(user_id): 226 | user.qq_guild[user_id] = event.guild_id 227 | PluginDataManager.write_plugin_data() 228 | 229 | msg_text = f"{PLUGIN.metadata.name}" \ 230 | f"{PLUGIN.metadata.description}\n" \ 231 | "具体用法:\n" \ 232 | f"{PLUGIN.metadata.usage.format(HEAD=COMMAND_BEGIN)}" 233 | send_result, action_failed = await send_private_msg( 234 | user_id=event.get_user_id(), 235 | message=msg_text, 236 | guild_id=int(event.guild_id) if isinstance(event, MessageCreateEvent) else None, 237 | use=bot 238 | ) 239 | if send_result: 240 | await direct_msg_respond.send("✔已发送私信,请查看私信消息") 241 | else: 242 | if isinstance(action_failed, QQGuildActionFailed): 243 | if action_failed.code == 304049: 244 | await direct_msg_respond.finish( 245 | f"⚠️发送私信失败,达到了机器人每日主动私信次数限制。错误信息:{action_failed!r}") 246 | elif action_failed.code == 304022: 247 | await direct_msg_respond.finish(f"⚠️发送私信失败,请换一个时间再试。错误信息:{action_failed!r}") 248 | elif isinstance(action_failed, AuditException): 249 | await direct_msg_respond.finish(f"⚠️发送私信失败,消息未通过审查。错误信息:{action_failed!r}") 250 | await direct_msg_respond.finish(f"⚠️发送私信失败,错误信息:{action_failed!r}") 251 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/model/__init__.py: -------------------------------------------------------------------------------- 1 | from .common import * 2 | from .config import * 3 | from .data import * 4 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/model/common.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import time 3 | from abc import abstractmethod 4 | from datetime import datetime 5 | from pathlib import Path 6 | from typing import Optional, Literal, NamedTuple, no_type_check, Union, Dict, Any, TypeVar, Tuple 7 | 8 | import pytz 9 | from pydantic import BaseModel 10 | 11 | __all__ = ["root_path", "data_path", "BaseModelWithSetter", "BaseModelWithUpdate", "Good", "GameRecord", "GameInfo", 12 | "Address", "MmtData", 13 | "Award", "GameSignInfo", "MissionData", "MissionState", "GenshinNote", "StarRailNote", "GenshinNoteNotice", 14 | "StarRailNoteNotice", "BaseApiStatus", "CreateMobileCaptchaStatus", "GetCookieStatus", "GetGoodDetailStatus", 15 | "ExchangeStatus", "MissionStatus", "GetFpStatus", "BoardStatus", "GenshinNoteStatus", "StarRailNoteStatus", 16 | "QueryGameTokenQrCodeStatus", "GeetestResult", "GeetestResultV4", "CommandUsage"] 17 | 18 | root_path = Path(__name__).parent.absolute() 19 | '''NoneBot2 机器人根目录''' 20 | 21 | data_path = root_path / "data" / "nonebot-plugin-mystool" 22 | '''插件数据保存目录''' 23 | 24 | 25 | class BaseModelWithSetter(BaseModel): 26 | """ 27 | 可以使用@property.setter的BaseModel 28 | 29 | 目前pydantic 1.10.7 无法使用@property.setter 30 | issue: https://github.com/pydantic/pydantic/issues/1577#issuecomment-790506164 31 | """ 32 | 33 | @no_type_check 34 | def __setattr__(self, name, value): 35 | try: 36 | super().__setattr__(name, value) 37 | except ValueError as e: 38 | setters = inspect.getmembers( 39 | self.__class__, 40 | predicate=lambda x: isinstance(x, property) and x.fset is not None 41 | ) 42 | for setter_name, func in setters: 43 | if setter_name == name: 44 | object.__setattr__(self, name, value) 45 | break 46 | else: 47 | raise e 48 | 49 | 50 | class BaseModelWithUpdate(BaseModel): 51 | """ 52 | 可以使用update方法的BaseModel 53 | """ 54 | _T = TypeVar("_T", bound=BaseModel) 55 | 56 | @abstractmethod 57 | def update(self, obj: Union[_T, Dict[str, Any]]) -> _T: 58 | """ 59 | 更新数据对象 60 | 61 | :param obj: 新的数据对象或属性字典 62 | :raise TypeError 63 | """ 64 | if isinstance(obj, type(self)): 65 | obj = obj.dict() 66 | items = filter(lambda x: x[0] in self.__fields__, obj.items()) 67 | for k, v in items: 68 | setattr(self, k, v) 69 | return self 70 | 71 | 72 | class Good(BaseModelWithUpdate): 73 | """ 74 | 商品数据 75 | """ 76 | type: int 77 | """为 1 时商品只有在指定时间开放兑换;为 0 时商品任何时间均可兑换""" 78 | next_time: Optional[int] 79 | """为 0 表示任何时间均可兑换或兑换已结束""" 80 | status: Optional[Literal["online", "not_in_sell"]] 81 | sale_start_time: Optional[int] 82 | time_by_detail: Optional[int] 83 | next_num: Optional[int] 84 | account_exchange_num: int 85 | """已经兑换次数""" 86 | account_cycle_limit: int 87 | """最多可兑换次数""" 88 | account_cycle_type: str 89 | """限购类型 Literal["forever", "month", "not_limit"]""" 90 | game_biz: Optional[str] 91 | """商品对应的游戏区服(如 hk4e_cn)(单独查询一个商品时)""" 92 | game: Optional[str] 93 | """商品对应的游戏""" 94 | unlimit: Optional[bool] 95 | """是否为不限量商品""" 96 | 97 | # 以下为实际会用到的属性 98 | 99 | name: Optional[str] 100 | """商品名称(单独查询一个商品时)""" 101 | goods_name: Optional[str] 102 | """商品名称(查询商品列表时)""" 103 | 104 | goods_id: str 105 | """商品ID(Good_ID)""" 106 | 107 | price: int 108 | """商品价格""" 109 | 110 | icon: str 111 | """商品图片链接""" 112 | 113 | def update(self, obj: Union["Good", Dict[str, Any]]) -> "Good": 114 | """ 115 | 更新商品信息 116 | 117 | :param obj: 新的商品数据 118 | :raise TypeError 119 | """ 120 | return super().update(obj) 121 | 122 | @property 123 | def time(self): 124 | """ 125 | 兑换时间 126 | 127 | :return: 如果返回`None`,说明任何时间均可兑换或兑换已结束。 128 | """ 129 | # "next_time" 为 0 表示任何时间均可兑换或兑换已结束 130 | if self.next_time == 0: 131 | return None 132 | # TODO: 暂时不知道为何 self.sale_start_time 是 str 类型而不是 int 类型 133 | sale_start_time = int(self.sale_start_time) if self.sale_start_time else 0 134 | if sale_start_time and time.time() < sale_start_time < self.next_time: 135 | return sale_start_time 136 | else: 137 | return self.next_time 138 | 139 | @property 140 | def time_text(self): 141 | """ 142 | 商品的兑换时间文本 143 | 144 | :return: 145 | 如果返回`None`,说明需要进一步查询商品详细信息才能获取兑换时间 146 | """ 147 | if self.time_end: 148 | return "已结束" 149 | elif self.time == 0: 150 | return None 151 | elif self.time_limited: 152 | from ..model.config import plugin_config 153 | if zone := plugin_config.preference.timezone: 154 | tz_info = pytz.timezone(zone) 155 | date_time = datetime.fromtimestamp(self.time, tz_info) 156 | else: 157 | date_time = datetime.fromtimestamp(self.time) 158 | return date_time.strftime("%Y-%m-%d %H:%M:%S") 159 | else: 160 | return "任何时间" 161 | 162 | @property 163 | def stoke_text(self): 164 | """ 165 | 商品的库存文本 166 | """ 167 | if self.time_end: 168 | return "无" 169 | elif self.time_limited: 170 | return str(self.num) 171 | else: 172 | return "不限" 173 | 174 | @property 175 | def time_limited(self): 176 | """ 177 | 是否为限时商品 178 | """ 179 | # 不限量被认为是不限时商品 180 | return not self.unlimit 181 | 182 | @property 183 | def time_end(self): 184 | """ 185 | 兑换是否已经结束 186 | """ 187 | return self.next_time == 0 188 | 189 | @property 190 | def num(self): 191 | """ 192 | 库存 193 | 如果返回`None`,说明库存不限 194 | """ 195 | if self.type != 1 and self.next_num == 0: 196 | return None 197 | else: 198 | return self.next_num 199 | 200 | @property 201 | def limit(self): 202 | """ 203 | 限购,返回元组 (已经兑换次数, 最多可兑换次数, 限购类型) 204 | """ 205 | return (self.account_exchange_num, 206 | self.account_cycle_limit, self.account_cycle_type) 207 | 208 | @property 209 | def is_virtual(self): 210 | """ 211 | 是否为虚拟商品 212 | """ 213 | return self.type == 2 214 | 215 | @property 216 | def general_name(self): 217 | return self.name or self.goods_name 218 | 219 | 220 | class GameRecord(BaseModel): 221 | """ 222 | 用户游戏数据 223 | """ 224 | region_name: str 225 | """服务器区名""" 226 | 227 | game_id: int 228 | """游戏ID""" 229 | 230 | level: int 231 | """用户游戏等级""" 232 | 233 | region: str 234 | """服务器区号""" 235 | 236 | game_role_id: str 237 | """用户游戏UID""" 238 | 239 | nickname: str 240 | """用户游戏昵称""" 241 | 242 | 243 | class GameInfo(BaseModel): 244 | """ 245 | 游戏信息数据 246 | """ 247 | # ABBR_TO_ID: Dict[int, Tuple[str, str]] = {} 248 | # ''' 249 | # 游戏ID(game_id)与缩写和全称的对应关系 250 | # >>> {游戏ID, (缩写, 全称)} 251 | # ''' 252 | id: int 253 | """游戏ID""" 254 | 255 | app_icon: str 256 | """游戏App图标链接(大)""" 257 | 258 | op_name: str 259 | """游戏代号(英文数字, 例如hk4e)""" 260 | 261 | en_name: str 262 | """游戏代号2(英文数字, 例如ys)""" 263 | 264 | icon: str 265 | """游戏图标链接(圆形, 小)""" 266 | 267 | name: str 268 | """游戏名称""" 269 | 270 | 271 | class Address(BaseModel): 272 | """ 273 | 地址数据 274 | """ 275 | connect_areacode: str 276 | """电话区号""" 277 | connect_mobile: str 278 | """电话号码""" 279 | 280 | # 以下为实际会用到的属性 281 | 282 | province_name: str 283 | """省""" 284 | 285 | city_name: str 286 | """市""" 287 | 288 | county_name: str 289 | """区/县""" 290 | 291 | addr_ext: str 292 | """详细地址""" 293 | 294 | connect_name: str 295 | """收货人姓名""" 296 | 297 | id: str 298 | """地址ID""" 299 | 300 | @property 301 | def phone(self) -> str: 302 | """ 303 | 联系电话(包含区号) 304 | """ 305 | return self.connect_areacode + " " + self.connect_mobile 306 | 307 | 308 | class MmtData(BaseModel): 309 | """ 310 | 短信验证码-人机验证任务申请-返回数据 311 | """ 312 | challenge: Optional[str] 313 | gt: Optional[str] 314 | """验证ID,即 极验文档 中的captchaId,极验后台申请得到""" 315 | mmt_key: Optional[str] 316 | """验证任务""" 317 | new_captcha: Optional[bool] 318 | """宕机情况下使用""" 319 | risk_type: Optional[str] 320 | """结合风控融合,指定验证形式""" 321 | success: Optional[int] 322 | use_v4: Optional[bool] 323 | """是否使用极验第四代 GT4""" 324 | 325 | 326 | class Award(BaseModel): 327 | """ 328 | 签到奖励数据 329 | """ 330 | name: str 331 | """签到获得的物品名称""" 332 | icon: str 333 | """物品图片链接""" 334 | cnt: int 335 | """物品数量""" 336 | 337 | 338 | class GameSignInfo(BaseModel): 339 | is_sign: bool 340 | """今日是否已经签到""" 341 | total_sign_day: int 342 | """已签多少天""" 343 | sign_cnt_missed: int 344 | """漏签多少天""" 345 | 346 | 347 | class MissionData(BaseModel): 348 | points: int 349 | """任务米游币奖励""" 350 | name: str 351 | """任务名字,如 讨论区签到""" 352 | mission_key: str 353 | """任务代号,如 continuous_sign""" 354 | threshold: int 355 | """任务完成的最多次数""" 356 | 357 | 358 | class MissionState(BaseModel): 359 | current_myb: int 360 | """用户当前米游币数量""" 361 | state_dict: Dict[str, Tuple[MissionData, int]] 362 | """所有任务对应的完成进度 {mission_key, (MissionData, 当前进度)}""" 363 | 364 | 365 | class GenshinNote(BaseModel): 366 | """ 367 | 原神实时便笺数据 (从米游社内相关页面API的返回数据初始化) 368 | """ 369 | current_resin: Optional[int] 370 | """当前树脂数量""" 371 | finished_task_num: Optional[int] 372 | """每日委托完成数""" 373 | current_expedition_num: Optional[int] 374 | """探索派遣 进行中的数量""" 375 | max_expedition_num: Optional[int] 376 | """探索派遣 最多派遣数""" 377 | current_home_coin: Optional[int] 378 | """洞天财瓮 未收取的宝钱数""" 379 | max_home_coin: Optional[int] 380 | """洞天财瓮 最多可容纳宝钱数""" 381 | transformer: Optional[Dict[str, Any]] 382 | """参量质变仪相关数据""" 383 | resin_recovery_time: Optional[int] 384 | """剩余树脂恢复时间""" 385 | 386 | @property 387 | def transformer_text(self): 388 | """ 389 | 参量质变仪状态文本 390 | """ 391 | try: 392 | if not self.transformer['obtained']: 393 | return '未获得' 394 | elif self.transformer['recovery_time']['reached']: 395 | return '已准备就绪' 396 | else: 397 | return f"{self.transformer['recovery_time']['Day']} 天" \ 398 | f"{self.transformer['recovery_time']['Hour']} 小时 " \ 399 | f"{self.transformer['recovery_time']['Minute']} 分钟" 400 | except KeyError: 401 | return None 402 | 403 | @property 404 | def resin_recovery_text(self): 405 | """ 406 | 剩余树脂恢复文本 407 | """ 408 | try: 409 | if not self.resin_recovery_time: 410 | return ':未获得时间数据' 411 | elif self.resin_recovery_time == 0: 412 | return '已准备就绪' 413 | else: 414 | recovery_timestamp = int(time.time()) + self.resin_recovery_time 415 | recovery_datetime = datetime.fromtimestamp(recovery_timestamp) 416 | return f"将在{recovery_datetime.strftime('%m-%d %H:%M')}回满" 417 | except KeyError: 418 | return None 419 | 420 | 421 | class StarRailNote(BaseModel): 422 | """ 423 | 崩铁实时便笺数据 (从米游社内相关页面API的返回数据初始化) 424 | """ 425 | current_stamina: Optional[int] 426 | """当前开拓力""" 427 | max_stamina: Optional[int] 428 | """最大开拓力""" 429 | stamina_recover_time: Optional[int] 430 | """剩余体力恢复时间""" 431 | current_train_score: Optional[int] 432 | """当前每日实训值""" 433 | max_train_score: Optional[int] 434 | """最大每日实训值""" 435 | current_rogue_score: Optional[int] 436 | """当前模拟宇宙积分""" 437 | max_rogue_score: Optional[int] 438 | """最大模拟宇宙积分""" 439 | accepted_expedition_num: Optional[int] 440 | """已接受委托数量""" 441 | total_expedition_num: Optional[int] 442 | """最大委托数量""" 443 | has_signed: Optional[bool] 444 | """当天是否签到""" 445 | 446 | @property 447 | def stamina_recover_text(self): 448 | """ 449 | 剩余体力恢复文本 450 | """ 451 | try: 452 | if not self.stamina_recover_time: 453 | return ':未获得时间数据' 454 | elif self.stamina_recover_time == 0: 455 | return '已准备就绪' 456 | else: 457 | recovery_timestamp = int(time.time()) + self.stamina_recover_time 458 | recovery_datetime = datetime.fromtimestamp(recovery_timestamp) 459 | return f"将在{recovery_datetime.strftime('%m-%d %H:%M')}回满" 460 | except KeyError: 461 | return None 462 | 463 | 464 | class GenshinNoteNotice(GenshinNote): 465 | """ 466 | 原神便笺通知状态 467 | """ 468 | current_resin: bool = False 469 | """是否达到阈值""" 470 | current_resin_full: bool = False 471 | """是否溢出""" 472 | current_home_coin: bool = False 473 | transformer: bool = False 474 | 475 | 476 | class StarRailNoteNotice(StarRailNote): 477 | """ 478 | 星穹铁道便笺通知状态 479 | """ 480 | current_stamina: bool = False 481 | """是否达到阈值""" 482 | current_stamina_full: bool = False 483 | """是否溢出""" 484 | current_train_score: bool = False 485 | current_rogue_score: bool = False 486 | 487 | 488 | class BaseApiStatus(BaseModel): 489 | """ 490 | API返回结果基类 491 | """ 492 | success = False 493 | """成功""" 494 | network_error = False 495 | """连接失败""" 496 | incorrect_return = False 497 | """服务器返回数据不正确""" 498 | login_expired = False 499 | """登录失效""" 500 | need_verify = False 501 | """需要进行人机验证""" 502 | invalid_ds = False 503 | """Headers DS无效""" 504 | 505 | def __bool__(self): 506 | return self.success 507 | 508 | @property 509 | def error_type(self): 510 | """ 511 | 返回错误类型 512 | """ 513 | for key, field in self.__fields__.items(): 514 | if field and key != "success": 515 | return key 516 | return None 517 | 518 | 519 | class CreateMobileCaptchaStatus(BaseApiStatus): 520 | """ 521 | 发送短信验证码 返回结果 522 | """ 523 | incorrect_geetest = False 524 | """人机验证结果数据无效""" 525 | not_registered = False 526 | """手机号码未注册""" 527 | invalid_phone_number = False 528 | """手机号码无效""" 529 | too_many_requests = False 530 | """发送过于频繁""" 531 | 532 | 533 | class GetCookieStatus(BaseApiStatus): 534 | """ 535 | 获取Cookie 返回结果 536 | """ 537 | incorrect_captcha = False 538 | """验证码错误""" 539 | missing_login_ticket = False 540 | """Cookies 缺少 login_ticket""" 541 | missing_bbs_uid = False 542 | """Cookies 缺少 bbs_uid (stuid, ltuid, ...)""" 543 | missing_cookie_token = False 544 | """Cookies 缺少 cookie_token""" 545 | missing_stoken = False 546 | """Cookies 缺少 stoken""" 547 | missing_stoken_v1 = False 548 | """Cookies 缺少 stoken_v1""" 549 | missing_stoken_v2 = False 550 | """Cookies 缺少 stoken_v2""" 551 | missing_mid = False 552 | """Cookies 缺少 mid""" 553 | 554 | 555 | class GetGoodDetailStatus(BaseApiStatus): 556 | """ 557 | 获取商品详细信息 返回结果 558 | """ 559 | good_not_existed = False 560 | 561 | 562 | class ExchangeStatus(BaseApiStatus): 563 | """ 564 | 兑换操作 返回结果 565 | """ 566 | missing_stoken = False 567 | """商品为游戏内物品,但 Cookies 缺少 stoken""" 568 | missing_mid = False 569 | """商品为游戏内物品,但 stoken 为 'v2' 类型同时 Cookies 缺少 mid""" 570 | missing_address = False 571 | """商品为实体物品,但未配置收货地址""" 572 | missing_game_uid = False 573 | """商品为游戏内物品,但未配置对应游戏的账号UID""" 574 | unsupported_game = False 575 | """暂不支持兑换对应分区/游戏的商品""" 576 | failed_getting_game_record = False 577 | """获取用户 GameRecord 失败""" 578 | init_required = False 579 | """未进行兑换任务初始化""" 580 | account_not_found = False 581 | """账号不存在""" 582 | 583 | 584 | class MissionStatus(BaseApiStatus): 585 | """ 586 | 米游币任务 返回结果 587 | """ 588 | failed_getting_post = False 589 | """获取文章失败""" 590 | already_signed = False 591 | """已经完成过签到""" 592 | 593 | 594 | class GetFpStatus(BaseApiStatus): 595 | """ 596 | 兑换操作 返回结果 597 | """ 598 | invalid_arguments = False 599 | """参数错误""" 600 | 601 | 602 | class BoardStatus(BaseApiStatus): 603 | """ 604 | 实时便笺 返回结果 605 | """ 606 | game_record_failed = False 607 | """获取用户游戏数据失败""" 608 | game_list_failed = False 609 | """获取游戏列表失败""" 610 | 611 | 612 | class GenshinNoteStatus(BoardStatus): 613 | """ 614 | 原神实时便笺 返回结果 615 | """ 616 | no_genshin_account = False 617 | """用户没有任何原神账户""" 618 | 619 | 620 | class StarRailNoteStatus(BoardStatus): 621 | """ 622 | 星铁实时便笺 返回结果 623 | """ 624 | no_starrail_account = False 625 | """用户没有任何星铁账户""" 626 | 627 | 628 | class QueryGameTokenQrCodeStatus(BaseApiStatus): 629 | """ 630 | 星铁实时便笺 返回结果 631 | """ 632 | qrcode_expired = False 633 | """二维码已过期""" 634 | qrcode_init = False 635 | """二维码未扫描""" 636 | qrcode_scanned = False 637 | """二维码已扫描但未确认""" 638 | 639 | 640 | GeetestResult = NamedTuple("GeetestResult", validate=str, seccode=str) 641 | """人机验证结果数据""" 642 | 643 | 644 | class GeetestResultV4(BaseModel): 645 | """ 646 | GEETEST GT4 人机验证结果数据 647 | """ 648 | captcha_id: str 649 | lot_number: str 650 | pass_token: str 651 | gen_time: str 652 | captcha_output: str 653 | 654 | 655 | class CommandUsage(BaseModel): 656 | """ 657 | 插件命令用法信息 658 | """ 659 | name: Optional[str] 660 | description: Optional[str] 661 | usage: Optional[str] 662 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/model/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from datetime import time, timedelta, datetime 4 | from pathlib import Path 5 | from typing import Union, Optional, Tuple, Any, Dict, TYPE_CHECKING 6 | 7 | import nonebot 8 | from nonebot.log import logger 9 | from pydantic import BaseModel, BaseSettings, validator 10 | 11 | from ..model.common import data_path 12 | 13 | if TYPE_CHECKING: 14 | IntStr = Union[int, str] 15 | 16 | __all__ = ["plugin_config_path", "Preference", 17 | "GoodListImageConfig", "SaltConfig", "DeviceConfig", "PluginConfig", "PluginEnv", "plugin_config", 18 | "plugin_env"] 19 | 20 | plugin_config_path = data_path / "configV2.json" 21 | """插件数据文件默认路径""" 22 | _driver = nonebot.get_driver() 23 | 24 | 25 | class Preference(BaseModel): 26 | """ 27 | 偏好设置 28 | """ 29 | github_proxy: Optional[str] = "https://mirror.ghproxy.com/" 30 | """GitHub加速代理 最终会拼接在原GitHub链接前面""" 31 | enable_connection_test: bool = True 32 | """是否开启连接测试""" 33 | connection_test_interval: Optional[float] = 30 34 | """连接测试间隔(单位:秒)""" 35 | timeout: float = 10 36 | """网络请求超时时间(单位:秒)""" 37 | max_retry_times: Optional[int] = 3 38 | """最大网络请求重试次数""" 39 | retry_interval: float = 2 40 | """网络请求重试间隔(单位:秒)(除兑换请求外)""" 41 | timezone: Optional[str] = "Asia/Shanghai" 42 | """兑换时所用的时区""" 43 | exchange_thread_count: int = 2 44 | """兑换线程数""" 45 | exchange_latency: Tuple[float, float] = (0, 0.5) 46 | """同一线程下,每个兑换请求之间的间隔时间""" 47 | exchange_duration: float = 5 48 | """兑换持续时间随机范围(单位:秒)""" 49 | enable_log_output: bool = True 50 | """是否保存日志""" 51 | log_head: str = "" 52 | '''日志开头字符串(只有把插件放进plugins目录手动加载时才需要设置)''' 53 | log_path: Optional[Path] = data_path / "mystool.log" 54 | """日志保存路径""" 55 | log_rotation: Union[str, int, time, timedelta] = "1 week" 56 | '''日志保留时长(需要按照格式设置)''' 57 | plugin_name: str = "nonebot_plugin_mystool" 58 | '''插件名(为模块名字,或于plugins目录手动加载时的目录名)''' 59 | encoding: str = "utf-8" 60 | '''文件读写编码''' 61 | max_user: int = 0 62 | '''支持最多用户数''' 63 | add_friend_accept: bool = True 64 | '''是否自动同意好友申请''' 65 | add_friend_welcome: bool = True 66 | '''用户添加机器人为好友以后,是否发送使用指引信息''' 67 | command_start: str = "" 68 | '''插件内部命令头(若为""空字符串则不启用)''' 69 | sleep_time: float = 2 70 | '''任务操作冷却时间(如米游币任务)''' 71 | plan_time: str = "00:30" 72 | '''每日自动签到和米游社任务的定时任务执行时间,格式为HH:MM''' 73 | resin_interval: int = 60 74 | '''每次检查原神便笺间隔,单位为分钟''' 75 | global_geetest: bool = False 76 | '''是否使用插件配置的全局打码接口,而不是用户个人配置的打码接口,默认关闭''' 77 | geetest_url: Optional[str] 78 | '''极验Geetest人机验证打码接口URL''' 79 | geetest_params: Optional[Dict[str, Any]] = None 80 | '''极验Geetest人机验证打码API发送的参数(除gt,challenge外)''' 81 | geetest_json: Optional[Dict[str, Any]] = { 82 | "gt": "{gt}", 83 | "challenge": "{challenge}" 84 | } 85 | '''极验Geetest人机验证打码API发送的JSON数据 `{gt}`, `{challenge}` 为占位符''' 86 | override_device_and_salt: bool = False 87 | """是否读取插件数据文件中的 device_config 设备配置 和 salt_config 配置而不是默认配置(一般情况不建议开启)""" 88 | enable_blacklist: bool = False 89 | """是否启用用户黑名单""" 90 | blacklist_path: Optional[Path] = data_path / "blacklist.txt" 91 | """用户黑名单文件路径""" 92 | enable_whitelist: bool = False 93 | """是否启用用户白名单""" 94 | whitelist_path: Optional[Path] = data_path / "whitelist.txt" 95 | """用户白名单文件路径""" 96 | enable_admin_list: bool = False 97 | """是否启用管理员名单""" 98 | admin_list_path: Optional[Path] = data_path / "admin_list.txt" 99 | """管理员名单文件路径""" 100 | game_token_app_id: str = "2" 101 | """米游社二维码登录的应用标识符""" 102 | qrcode_query_interval: float = 1 103 | """检查米游社登录二维码扫描情况的请求间隔(单位:秒)""" 104 | qrcode_wait_time: float = 120 105 | """等待米游社登录二维码扫描的最长时间(单位:秒)""" 106 | 107 | @validator("log_path", allow_reuse=True) 108 | def _(cls, v: Optional[Path]): 109 | absolute_path = v.absolute() 110 | if not os.path.exists(absolute_path) or not os.path.isfile(absolute_path): 111 | absolute_parent = absolute_path.parent 112 | try: 113 | os.makedirs(absolute_parent, exist_ok=True) 114 | except PermissionError: 115 | logger.warning(f"程序没有创建日志目录 {absolute_parent} 的权限") 116 | elif not os.access(absolute_path, os.W_OK): 117 | logger.warning(f"程序没有写入日志文件 {absolute_path} 的权限") 118 | return v 119 | 120 | @property 121 | def notice_time(self) -> bool: 122 | now_hour = datetime.now().hour 123 | now_minute = datetime.now().minute 124 | set_time = "20:00" 125 | notice_time = int(set_time[:2]) * 60 + int(set_time[3:]) 126 | start_time = notice_time - self.resin_interval 127 | end_time = notice_time + self.resin_interval 128 | return start_time <= (now_hour * 60 + now_minute) % (24 * 60) <= end_time 129 | 130 | 131 | class GoodListImageConfig(BaseModel): 132 | """ 133 | 商品列表输出图片设置 134 | """ 135 | ICON_SIZE: Tuple[int, int] = (600, 600) 136 | '''商品预览图在最终结果图中的大小''' 137 | WIDTH: int = 2000 138 | '''最终结果图宽度''' 139 | PADDING_ICON: int = 0 140 | '''展示图与展示图之间的间隙 高''' 141 | PADDING_TEXT_AND_ICON_Y: int = 125 142 | '''文字顶部与展示图顶部之间的距离 高''' 143 | PADDING_TEXT_AND_ICON_X: int = 10 144 | '''文字与展示图之间的横向距离 宽''' 145 | FONT_PATH: Union[Path, str, None] = None 146 | ''' 147 | 字体文件路径(若使用计算机已经安装的字体,直接填入字体名称,若为None则自动下载字体) 148 | 149 | 开源字体 Source Han Sans 思源黑体 150 | https://github.com/adobe-fonts/source-han-sans 151 | ''' 152 | FONT_SIZE: int = 50 153 | '''字体大小''' 154 | SAVE_PATH: Path = data_path 155 | '''商品列表图片缓存目录''' 156 | MULTI_PROCESS: bool = sys.platform != "win32" 157 | '''是否使用多进程生成图片(如果生成图片时崩溃,可尝试关闭此选项)''' 158 | 159 | 160 | class SaltConfig(BaseModel): 161 | """ 162 | 生成Headers - DS所用salt值,非必要请勿修改 163 | """ 164 | SALT_IOS: str = "9ttJY72HxbjwWRNHJvn0n2AYue47nYsK" 165 | '''LK2 - 生成Headers iOS DS所需的salt''' 166 | SALT_ANDROID: str = "BIPaooxbWZW02fGHZL1If26mYCljPgst" 167 | '''K2 - 生成Headers Android DS所需的salt''' 168 | SALT_DATA: str = "t0qEgfub6cvueAPgR5m9aQWWVciEer7v" 169 | '''6X - Android 设备传入content生成 DS 所需的 salt''' 170 | SALT_PARAMS: str = "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs" 171 | '''4X - Android 设备传入url参数生成 DS 所需的 salt''' 172 | SALT_PROD: str = "JwYDpKvLj6MrMqqYU6jTKF17KNO2PXoS" 173 | '''PROD - 账号相关''' 174 | 175 | class Config(Preference.Config): 176 | pass 177 | 178 | 179 | class DeviceConfig(BaseModel): 180 | """ 181 | 设备信息 182 | Headers所用的各种数据,非必要请勿修改 183 | """ 184 | USER_AGENT_MOBILE: str = ("Mozilla/5.0 (iPhone; CPU iPhone OS 15_4 like Mac OS X) " 185 | "AppleWebKit/605.1.15 (KHTML, like Gecko) miHoYoBBS/2.55.1") 186 | '''移动端 User-Agent(Mozilla UA)''' 187 | USER_AGENT_PC: str = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) " 188 | "Version/16.0 Safari/605.1.15") 189 | '''桌面端 User-Agent(Mozilla UA)''' 190 | USER_AGENT_OTHER: str = "Hyperion/275 CFNetwork/1402.0.8 Darwin/22.2.0" 191 | '''获取用户 ActionTicket 时Headers所用的 User-Agent''' 192 | USER_AGENT_ANDROID: str = ("Mozilla/5.0 (Linux; Android 11; MI 8 SE Build/RQ3A.211001.001; wv) AppleWebKit/537.36 " 193 | "(KHTML, like Gecko) Version/4.0 Chrome/104.0.5112.97 Mobile Safari/537.36 " 194 | "miHoYoBBS/2.55.1") 195 | '''安卓端 User-Agent(Mozilla UA)''' 196 | USER_AGENT_ANDROID_OTHER: str = "okhttp/4.9.3" 197 | '''安卓端 User-Agent(专用于米游币任务等)''' 198 | USER_AGENT_WIDGET: str = "WidgetExtension/231 CFNetwork/1390 Darwin/22.0.0" 199 | '''iOS 小组件 User-Agent(原神实时便笺)''' 200 | 201 | X_RPC_DEVICE_MODEL_MOBILE: str = "iPhone10,2" 202 | '''移动端 x-rpc-device_model''' 203 | X_RPC_DEVICE_MODEL_PC: str = "OS X 10.15.7" 204 | '''桌面端 x-rpc-device_model''' 205 | X_RPC_DEVICE_MODEL_ANDROID: str = "MI 8 SE" 206 | '''安卓端 x-rpc-device_model''' 207 | 208 | X_RPC_DEVICE_NAME_MOBILE: str = "iPhone" 209 | '''移动端 x-rpc-device_name''' 210 | X_RPC_DEVICE_NAME_PC: str = "Microsoft Edge 103.0.1264.62" 211 | '''桌面端 x-rpc-device_name''' 212 | X_RPC_DEVICE_NAME_ANDROID: str = "Xiaomi MI 8 SE" 213 | '''安卓端 x-rpc-device_name''' 214 | 215 | X_RPC_SYS_VERSION: str = "16.2" 216 | '''Headers所用的 x-rpc-sys_version''' 217 | X_RPC_SYS_VERSION_ANDROID: str = "11" 218 | '''安卓端 x-rpc-sys_version''' 219 | 220 | X_RPC_CHANNEL: str = "appstore" 221 | '''Headers所用的 x-rpc-channel''' 222 | X_RPC_CHANNEL_ANDROID: str = "miyousheluodi" 223 | '''安卓端 x-rpc-channel''' 224 | 225 | X_RPC_APP_VERSION: str = "2.63.1" 226 | '''Headers所用的 x-rpc-app_version''' 227 | X_RPC_PLATFORM: str = "ios" 228 | '''Headers所用的 x-rpc-platform''' 229 | UA: str = "\".Not/A)Brand\";v=\"99\", \"Microsoft Edge\";v=\"103\", \"Chromium\";v=\"103\"" 230 | '''Headers所用的 sec-ch-ua''' 231 | UA_PLATFORM: str = "\"macOS\"" 232 | '''Headers所用的 sec-ch-ua-platform''' 233 | 234 | class Config(Preference.Config): 235 | pass 236 | 237 | 238 | class PluginConfig(BaseSettings): 239 | preference = Preference() 240 | good_list_image_config = GoodListImageConfig() 241 | 242 | 243 | class PluginEnv(BaseSettings): 244 | salt_config = SaltConfig() 245 | device_config = DeviceConfig() 246 | 247 | class Config(BaseSettings.Config): 248 | env_prefix = "mystool_" 249 | env_file = '.env' 250 | 251 | 252 | if plugin_config_path.exists() and plugin_config_path.is_file(): 253 | plugin_config = PluginConfig.parse_file(plugin_config_path) 254 | else: 255 | plugin_config = PluginConfig() 256 | try: 257 | str_data = plugin_config.json(indent=4) 258 | plugin_config_path.parent.mkdir(parents=True, exist_ok=True) 259 | with open(plugin_config_path, "w", encoding="utf-8") as f: 260 | f.write(str_data) 261 | except (AttributeError, TypeError, ValueError, PermissionError): 262 | logger.exception(f"创建插件配置文件失败,请检查是否有权限读取和写入 {plugin_config_path}") 263 | raise 264 | else: 265 | logger.info(f"插件配置文件 {plugin_config_path} 不存在,已创建默认插件配置文件。") 266 | 267 | # TODO: 可能产生 #271 的问题 https://github.com/Ljzd-PRO/nonebot-plugin-mystool/issues/271 268 | # @_driver.on_startup 269 | # def _(): 270 | # """将 ``PluginMetadata.config`` 设为定义的插件配置对象 ``plugin_config``""" 271 | # plugin = nonebot.plugin.get_plugin(plugin_config.preference.plugin_name) 272 | # plugin.metadata.config = plugin_config 273 | 274 | 275 | plugin_env = PluginEnv() 276 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/model/data.py: -------------------------------------------------------------------------------- 1 | import json 2 | from json import JSONDecodeError 3 | from typing import Union, Optional, Any, Dict, TYPE_CHECKING, AbstractSet, \ 4 | Mapping, Set, Literal, List 5 | from uuid import UUID, uuid4 6 | 7 | from httpx import Cookies 8 | from nonebot.log import logger 9 | from pydantic import BaseModel, ValidationError, validator, Field 10 | 11 | from .._version import __version__ 12 | from ..model.common import data_path, BaseModelWithSetter, Address, BaseModelWithUpdate, Good, GameRecord 13 | 14 | if TYPE_CHECKING: 15 | IntStr = Union[int, str] 16 | DictStrAny = Dict[str, Any] 17 | AbstractSetIntStr = AbstractSet[IntStr] 18 | MappingIntStrAny = Mapping[IntStr, Any] 19 | 20 | __all__ = ["plugin_data_path", "BBSCookies", "UserAccount", "ExchangePlan", "ExchangeResult", "uuid4_validate", 21 | "UserData", "PluginData", "PluginDataManager"] 22 | 23 | plugin_data_path = data_path / "dataV2.json" 24 | _uuid_set: Set[str] = set() 25 | """已使用的用户UUID密钥集合""" 26 | _new_uuid_in_init = False 27 | """插件反序列化用户数据时,是否生成了新的UUID密钥""" 28 | 29 | 30 | class BBSCookies(BaseModelWithSetter, BaseModelWithUpdate): 31 | """ 32 | 米游社Cookies数据 33 | 34 | # 测试 is_correct() 方法 35 | 36 | >>> assert BBSCookies().is_correct() is False 37 | >>> assert BBSCookies(stuid="123", stoken="123", cookie_token="123").is_correct() is True 38 | 39 | # 测试 bbs_uid getter 40 | 41 | >>> bbs_cookies = BBSCookies() 42 | >>> assert not bbs_cookies.bbs_uid 43 | >>> assert BBSCookies(stuid="123").bbs_uid == "123" 44 | 45 | # 测试 bbs_uid setter 46 | 47 | >>> bbs_cookies.bbs_uid = "123" 48 | >>> assert bbs_cookies.bbs_uid == "123" 49 | 50 | # 检查构造函数内所用的 stoken setter 51 | 52 | >>> bbs_cookies = BBSCookies(stoken="abcd1234") 53 | >>> assert bbs_cookies.stoken_v1 and not bbs_cookies.stoken_v2 54 | >>> bbs_cookies = BBSCookies(stoken="v2_abcd1234==") 55 | >>> assert bbs_cookies.stoken_v2 and not bbs_cookies.stoken_v1 56 | >>> assert bbs_cookies.stoken == "v2_abcd1234==" 57 | 58 | # 检查 stoken setter 59 | 60 | >>> bbs_cookies = BBSCookies(stoken="abcd1234") 61 | >>> bbs_cookies.stoken = "v2_abcd1234==" 62 | >>> assert bbs_cookies.stoken_v2 == "v2_abcd1234==" 63 | >>> assert bbs_cookies.stoken_v1 == "abcd1234" 64 | 65 | # 检查 .dict 方法能否生成包含 stoken_2 类型的 stoken 的字典 66 | 67 | >>> bbs_cookies = BBSCookies() 68 | >>> bbs_cookies.stoken_v1 = "abcd1234" 69 | >>> bbs_cookies.stoken_v2 = "v2_abcd1234==" 70 | >>> assert bbs_cookies.dict(v2_stoken=True)["stoken"] == "v2_abcd1234==" 71 | 72 | # 检查是否有多余的字段 73 | 74 | >>> bbs_cookies = BBSCookies(stuid="123") 75 | >>> assert all(bbs_cookies.dict()) 76 | >>> assert all(map(lambda x: x not in bbs_cookies, ["stoken_v1", "stoken_v2"])) 77 | 78 | # 测试 update 方法 79 | 80 | >>> bbs_cookies = BBSCookies(stuid="123") 81 | >>> assert bbs_cookies.update({"stuid": "456", "stoken": "abc"}) is bbs_cookies 82 | >>> assert bbs_cookies.stuid == "456" 83 | >>> assert bbs_cookies.stoken == "abc" 84 | 85 | >>> bbs_cookies = BBSCookies(stuid="123") 86 | >>> new_cookies = BBSCookies(stuid="456", stoken="abc") 87 | >>> assert bbs_cookies.update(new_cookies) is bbs_cookies 88 | >>> assert bbs_cookies.stuid == "456" 89 | >>> assert bbs_cookies.stoken == "abc" 90 | """ 91 | stuid: Optional[str] 92 | """米游社UID""" 93 | ltuid: Optional[str] 94 | """米游社UID""" 95 | account_id: Optional[str] 96 | """米游社UID""" 97 | login_uid: Optional[str] 98 | """米游社UID""" 99 | 100 | stoken_v1: Optional[str] 101 | """保存stoken_v1,方便后续使用""" 102 | stoken_v2: Optional[str] 103 | """保存stoken_v2,方便后续使用""" 104 | 105 | cookie_token: Optional[str] 106 | login_ticket: Optional[str] 107 | ltoken: Optional[str] 108 | mid: Optional[str] 109 | 110 | def __init__(self, **data: Any): 111 | super().__init__(**data) 112 | stoken = data.get("stoken") 113 | if stoken: 114 | self.stoken = stoken 115 | 116 | def is_correct(self) -> bool: 117 | """判断是否为正确的Cookies""" 118 | if self.bbs_uid and self.stoken and self.cookie_token: 119 | return True 120 | else: 121 | return False 122 | 123 | @property 124 | def bbs_uid(self): 125 | """ 126 | 获取米游社UID 127 | """ 128 | uid = None 129 | for value in [self.stuid, self.ltuid, self.account_id, self.login_uid]: 130 | if value: 131 | uid = value 132 | break 133 | return uid or None 134 | 135 | @bbs_uid.setter 136 | def bbs_uid(self, value: str): 137 | self.stuid = value 138 | self.ltuid = value 139 | self.account_id = value 140 | self.login_uid = value 141 | 142 | @property 143 | def stoken(self): 144 | """ 145 | 获取stoken 146 | :return: 优先返回 self.stoken_v1 147 | """ 148 | if self.stoken_v1: 149 | return self.stoken_v1 150 | elif self.stoken_v2: 151 | return self.stoken_v2 152 | else: 153 | return None 154 | 155 | @stoken.setter 156 | def stoken(self, value): 157 | if value.startswith("v2_"): 158 | self.stoken_v2 = value 159 | else: 160 | self.stoken_v1 = value 161 | 162 | def update(self, cookies: Union[Dict[str, str], Cookies, "BBSCookies"]): 163 | """ 164 | 更新Cookies 165 | """ 166 | if not isinstance(cookies, BBSCookies): 167 | self.stoken = cookies.get("stoken") or self.stoken 168 | self.bbs_uid = cookies.get("bbs_uid") or self.bbs_uid 169 | cookies.pop("stoken", None) 170 | cookies.pop("bbs_uid", None) 171 | return super().update(cookies) 172 | 173 | def dict(self, *, 174 | include: Optional[Union['AbstractSetIntStr', 'MappingIntStrAny']] = None, 175 | exclude: Optional[Union['AbstractSetIntStr', 'MappingIntStrAny']] = None, 176 | by_alias: bool = False, 177 | skip_defaults: Optional[bool] = None, exclude_unset: bool = False, exclude_defaults: bool = False, 178 | exclude_none: bool = False, v2_stoken: bool = False, 179 | cookie_type: bool = False) -> 'DictStrAny': 180 | """ 181 | 获取Cookies字典 182 | 183 | v2_stoken: stoken 字段是否使用 stoken_v2 184 | cookie_type: 是否返回符合Cookie类型的字典(没有自定义的stoken_v1、stoken_v2键) 185 | """ 186 | # 保证 stuid, ltuid 等字段存在 187 | self.bbs_uid = self.bbs_uid 188 | cookies_dict = super().dict(include=include, exclude=exclude, by_alias=by_alias, skip_defaults=skip_defaults, 189 | exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, 190 | exclude_none=exclude_none) 191 | if v2_stoken and self.stoken_v2: 192 | cookies_dict["stoken"] = self.stoken_v2 193 | else: 194 | cookies_dict["stoken"] = self.stoken_v1 195 | 196 | if cookie_type: 197 | # 去除自定义的 stoken_v1, stoken_v2 字段 198 | cookies_dict.pop("stoken_v1") 199 | cookies_dict.pop("stoken_v2") 200 | 201 | # 去除空的字段 202 | empty_key = set() 203 | for key, value in cookies_dict.items(): 204 | if not value: 205 | empty_key.add(key) 206 | [cookies_dict.pop(key) for key in empty_key] 207 | 208 | return cookies_dict 209 | 210 | 211 | class UserAccount(BaseModelWithSetter): 212 | """ 213 | 米游社账户数据 214 | 215 | >>> user_account = UserAccount( 216 | >>> cookies=BBSCookies(), 217 | >>> device_id_ios="DBB8886C-C88A-4E12-A407-BE295E95E084", 218 | >>> device_id_android="64561CE4-5F43-41D7-B92F-41CEFABC7ABF" 219 | >>> ) 220 | >>> assert isinstance(user_account, UserAccount) 221 | >>> user_account.bbs_uid = "123" 222 | >>> assert user_account.bbs_uid == "123" 223 | """ 224 | phone_number: Optional[str] 225 | """手机号""" 226 | cookies: BBSCookies 227 | """Cookies""" 228 | address: Optional[Address] 229 | """收货地址""" 230 | 231 | device_id_ios: str 232 | """iOS设备用 deviceID""" 233 | device_id_android: str 234 | """安卓设备用 deviceID""" 235 | device_fp: Optional[str] 236 | """iOS设备用 deviceFp""" 237 | enable_mission: bool = True 238 | '''是否开启米游币任务计划''' 239 | enable_game_sign: bool = True 240 | '''是否开启米游社游戏签到计划''' 241 | enable_resin: bool = True 242 | '''是否开启便笺提醒''' 243 | platform: Literal["ios", "android"] = "ios" 244 | '''设备平台''' 245 | game_sign_games: List[str] = [ 246 | "GenshinImpact", 247 | "HonkaiImpact3", 248 | "HoukaiGakuen2", 249 | "TearsOfThemis", 250 | "StarRail", 251 | "ZenlessZoneZero" 252 | ] 253 | '''允许签到的游戏列表''' 254 | mission_games: List[str] = ["BBSMission"] 255 | '''在哪些板块执行米游币任务计划 为 BaseMission 子类名称''' 256 | user_stamina_threshold: int = 240 257 | '''崩铁便笺体力提醒阈值,0为一直提醒''' 258 | user_resin_threshold: int = 200 259 | '''原神便笺树脂提醒阈值,0为一直提醒''' 260 | 261 | def __init__(self, **data: Any): 262 | if not data.get("device_id_ios") or not data.get("device_id_android"): 263 | from ..utils import generate_device_id 264 | if not data.get("device_id_ios"): 265 | data.setdefault("device_id_ios", generate_device_id()) 266 | if not data.get("device_id_android"): 267 | data.setdefault("device_id_android", generate_device_id()) 268 | 269 | super().__init__(**data) 270 | 271 | @property 272 | def bbs_uid(self): 273 | """ 274 | 获取米游社UID 275 | """ 276 | return self.cookies.bbs_uid 277 | 278 | @bbs_uid.setter 279 | def bbs_uid(self, value: str): 280 | self.cookies.bbs_uid = value 281 | 282 | @property 283 | def display_name(self): 284 | """ 285 | 显示名称 286 | """ 287 | from ..utils.common import blur_phone 288 | return f"{self.bbs_uid}({blur_phone(self.phone_number)})" if self.phone_number else self.bbs_uid 289 | 290 | 291 | class ExchangePlan(BaseModel): 292 | """ 293 | 兑换计划数据类 294 | """ 295 | 296 | good: Good 297 | """商品""" 298 | address: Optional[Address] 299 | """地址ID""" 300 | account: UserAccount 301 | """米游社账号""" 302 | game_record: Optional[GameRecord] 303 | """商品对应的游戏的玩家账号""" 304 | 305 | def __hash__(self): 306 | return hash( 307 | ( 308 | self.good.goods_id, 309 | self.good.time, 310 | self.address.id if self.address else None, 311 | self.account.bbs_uid, 312 | self.game_record.game_role_id if self.game_record else None 313 | ) 314 | ) 315 | 316 | class CustomDict(dict): 317 | _hash: int 318 | 319 | def __hash__(self): 320 | return self._hash 321 | 322 | def dict( 323 | self, 324 | *, 325 | include: Optional[Union['AbstractSetIntStr', 'MappingIntStrAny']] = None, 326 | exclude: Optional[Union['AbstractSetIntStr', 'MappingIntStrAny']] = None, 327 | by_alias: bool = False, 328 | skip_defaults: Optional[bool] = None, 329 | exclude_unset: bool = False, 330 | exclude_defaults: bool = False, 331 | exclude_none: bool = False, 332 | ) -> 'DictStrAny': 333 | """ 334 | 重写 dict 方法,使其返回的 dict 可以被 hash 335 | """ 336 | normal_dict = super().dict(include=include, exclude=exclude, by_alias=by_alias, skip_defaults=skip_defaults, 337 | exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, 338 | exclude_none=exclude_none) 339 | hashable_dict = ExchangePlan.CustomDict(normal_dict) 340 | hashable_dict._hash = hash(self) 341 | return hashable_dict 342 | 343 | 344 | class ExchangeResult(BaseModel): 345 | """ 346 | 兑换结果数据类 347 | """ 348 | result: bool 349 | """兑换结果""" 350 | return_data: dict 351 | """返回数据""" 352 | plan: ExchangePlan 353 | """兑换计划""" 354 | 355 | 356 | def uuid4_validate(v): 357 | """ 358 | 验证UUID是否为合法的UUIDv4 359 | 360 | :param v: UUID 361 | """ 362 | try: 363 | UUID(v, version=4) 364 | except Exception: 365 | return False 366 | else: 367 | return True 368 | 369 | 370 | class UserData(BaseModelWithSetter): 371 | """ 372 | 用户数据类 373 | 374 | >>> userdata = UserData() 375 | >>> hash(userdata) 376 | """ 377 | enable_notice: bool = True 378 | """是否开启通知""" 379 | geetest_url: Optional[str] 380 | '''极验Geetest人机验证打码接口URL''' 381 | geetest_params: Optional[Dict[str, Any]] = None 382 | '''极验Geetest人机验证打码API发送的参数(除gt,challenge外)''' 383 | uuid: Optional[str] = None 384 | """用户UUID密钥,用于不同NoneBot适配器平台之间的数据同步,因此不可泄露""" 385 | qq_guild: Optional[Dict[str, int]] = {} 386 | """储存用户所在的QQ频道ID {用户ID : 频道ID}""" 387 | qq_guilds: Optional[Dict[str, List[int]]] = Field(default={}, exclude=True) 388 | """旧版(v2.1.0 之前)储存用户所在的QQ频道ID {用户ID : [频道ID]}""" 389 | exchange_plans: Union[Set[ExchangePlan], List[ExchangePlan]] = set() 390 | """兑换计划列表""" 391 | accounts: Dict[str, UserAccount] = {} 392 | """储存一些已绑定的账号数据""" 393 | 394 | @validator("uuid") 395 | def uuid_validator(cls, v): 396 | """ 397 | 验证UUID是否为合法的UUIDv4 398 | 399 | :raises ValueError: UUID格式错误,不是合法的UUIDv4 400 | """ 401 | if v is None and not uuid4_validate(v): 402 | raise ValueError("UUID格式错误,不是合法的UUIDv4") 403 | 404 | def __init__(self, **data: Any): 405 | global _new_uuid_in_init 406 | super().__init__(**data) 407 | 408 | exchange_plans = self.exchange_plans 409 | self.exchange_plans = set() 410 | for plan in exchange_plans: 411 | plan = ExchangePlan.parse_obj(plan) 412 | self.exchange_plans.add(plan) 413 | 414 | if self.uuid is None: 415 | new_uuid = uuid4() 416 | while str(new_uuid) in _uuid_set: 417 | new_uuid = uuid4() 418 | self.uuid = str(new_uuid) 419 | _new_uuid_in_init = True 420 | _uuid_set.add(self.uuid) 421 | 422 | # 读取旧版配置中的 qq_guilds 信息,对每个账号取第一个 GuildID 值以生成新的 qq_guild Dict 423 | if not self.qq_guild: 424 | self.qq_guild = {k: v[0] for k, v in filter(lambda x: x[1], self.qq_guilds.items())} 425 | 426 | def __hash__(self): 427 | return hash(self.uuid) 428 | 429 | 430 | class PluginData(BaseModel): 431 | version: str = __version__ 432 | """创建插件数据文件时的版本号""" 433 | user_bind: Optional[Dict[str, str]] = {} 434 | '''不同NoneBot适配器平台的用户数据绑定关系(如QQ聊天和QQ频道)(空用户数据:被绑定用户数据)''' 435 | users: Dict[str, UserData] = {} 436 | '''所有用户数据''' 437 | 438 | def do_user_bind(self, src: str = None, dst: str = None, write: bool = False): 439 | """ 440 | 执行用户数据绑定同步,将src指向dst的用户数据,即src处的数据将会被dst处的数据对象替换 441 | 442 | :param src: 源用户数据,为空则读取 self.user_bind 并执行全部绑定 443 | :param dst: 目标用户数据,为空则读取 self.user_bind 并执行全部绑定 444 | :param write: 是否写入插件数据文件 445 | """ 446 | if None in [src, dst]: 447 | for x, y in self.user_bind.items(): 448 | try: 449 | self.users[x] = self.users[y] 450 | except KeyError: 451 | logger.error(f"用户数据绑定失败,目标用户 {y} 不存在") 452 | else: 453 | try: 454 | self.user_bind[src] = dst 455 | self.users[src] = self.users[dst] 456 | except KeyError: 457 | logger.error(f"用户数据绑定失败,目标用户 {dst} 不存在") 458 | else: 459 | if write: 460 | PluginDataManager.write_plugin_data() 461 | 462 | def __init__(self, **data: Any): 463 | super().__init__(**data) 464 | self.do_user_bind(write=True) 465 | 466 | class Config: 467 | json_encoders = UserAccount.Config.json_encoders 468 | 469 | 470 | class PluginDataManager: 471 | plugin_data: Optional[PluginData] = None 472 | """加载出的插件数据对象""" 473 | 474 | @classmethod 475 | def load_plugin_data(cls): 476 | """ 477 | 加载插件数据文件 478 | """ 479 | if plugin_data_path.exists() and plugin_data_path.is_file(): 480 | try: 481 | with open(plugin_data_path, "r") as f: 482 | plugin_data_dict = json.load(f) 483 | # 读取完整的插件数据 484 | cls.plugin_data = PluginData.parse_obj(plugin_data_dict) 485 | except (ValidationError, JSONDecodeError): 486 | logger.exception(f"读取插件数据文件失败,请检查插件数据文件 {plugin_data_path} 格式是否正确") 487 | raise 488 | except Exception: 489 | logger.exception( 490 | f"读取插件数据文件失败,请检查插件数据文件 {plugin_data_path} 是否存在且有权限读取和写入") 491 | raise 492 | else: 493 | cls.plugin_data = PluginData() 494 | try: 495 | str_data = cls.plugin_data.json(indent=4) 496 | plugin_data_path.parent.mkdir(parents=True, exist_ok=True) 497 | with open(plugin_data_path, "w", encoding="utf-8") as f: 498 | f.write(str_data) 499 | except (AttributeError, TypeError, ValueError, PermissionError): 500 | logger.exception(f"创建插件数据文件失败,请检查是否有权限读取和写入 {plugin_data_path}") 501 | raise 502 | else: 503 | logger.info(f"插件数据文件 {plugin_data_path} 不存在,已创建默认插件数据文件。") 504 | 505 | @classmethod 506 | def write_plugin_data(cls): 507 | """ 508 | 写入插件数据文件 509 | 510 | :return: 是否成功 511 | """ 512 | try: 513 | str_data = cls.plugin_data.json(indent=4) 514 | except (AttributeError, TypeError, ValueError): 515 | logger.exception("数据对象序列化失败,可能是数据类型错误") 516 | return False 517 | else: 518 | with open(plugin_data_path, "w", encoding="utf-8") as f: 519 | f.write(str_data) 520 | return True 521 | 522 | 523 | PluginDataManager.load_plugin_data() 524 | 525 | # 如果插件数据文件加载后,发现有用户没有UUID密钥,进行了生成,则需要保存写入 526 | if _new_uuid_in_init: 527 | PluginDataManager.write_plugin_data() 528 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/model/upgrade/__init__.py: -------------------------------------------------------------------------------- 1 | from .common import * 2 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/model/upgrade/common.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Optional, Dict, TYPE_CHECKING 2 | 3 | from nonebot.log import logger 4 | from pydantic import BaseSettings 5 | 6 | from ..._version import __version__ 7 | from ...model.common import data_path 8 | from ...model.upgrade.configV2 import Preference, SaltConfig, DeviceConfig, GoodListImageConfig, PluginConfig, \ 9 | plugin_config_path, \ 10 | PluginEnv 11 | from ...model.upgrade.dataV2 import UserData, UserAccount, PluginData, plugin_data_path 12 | 13 | if TYPE_CHECKING: 14 | IntStr = Union[int, str] 15 | 16 | __all__ = ["plugin_data_path_v1", "PluginDataV1", "upgrade_plugin_data"] 17 | plugin_data_path_v1 = data_path / "plugin_data.json" 18 | 19 | 20 | class PluginDataV1(BaseSettings): 21 | version: str = __version__ 22 | """创建插件数据文件时的版本号""" 23 | preference: Preference = Preference() 24 | """偏好设置""" 25 | salt_config: SaltConfig = SaltConfig() 26 | """生成Headers - DS所用salt值""" 27 | device_config: DeviceConfig = DeviceConfig() 28 | """设备信息""" 29 | good_list_image_config: GoodListImageConfig = GoodListImageConfig() 30 | """商品列表输出图片设置""" 31 | user_bind: Optional[Dict[str, str]] = {} 32 | '''不同NoneBot适配器平台的用户数据绑定关系(如QQ聊天和QQ频道)(空用户数据:被绑定用户数据)''' 33 | users: Dict[str, UserData] = {} 34 | '''所有用户数据''' 35 | 36 | class Config: 37 | json_encoders = UserAccount.Config.json_encoders 38 | 39 | 40 | def upgrade_plugin_data(): 41 | if plugin_data_path_v1.exists() and plugin_data_path_v1.is_file(): 42 | logger.warning("发现V1旧版插件数据文件(包含配置和插件数据),正在升级") 43 | plugin_data_v1 = PluginDataV1.parse_file(plugin_data_path_v1, encoding="utf-8") 44 | 45 | plugin_config_v2 = PluginConfig() 46 | plugin_config_v2.preference = plugin_data_v1.preference 47 | plugin_config_v2.good_list_image_config = plugin_data_v1.good_list_image_config 48 | logger.success("成功提取V1旧版插件数据文件中的 PluginConfig") 49 | 50 | plugin_env = PluginEnv() 51 | plugin_env.salt_config = plugin_data_v1.salt_config 52 | plugin_env.device_config = plugin_data_v1.device_config 53 | logger.success("成功提取V1旧版插件数据文件中的 PluginEnv") 54 | 55 | plugin_data_v2 = PluginData() 56 | plugin_data_v2.user_bind = plugin_data_v1.user_bind 57 | plugin_data_v2.users = plugin_data_v1.users 58 | logger.success("成功提取V1旧版插件数据文件中的 PluginData") 59 | 60 | plugin_env_text = "" 61 | plugin_env_text += "\n".join( 62 | map( 63 | lambda x: f"{plugin_env.Config.env_prefix.upper()}" 64 | "SALT_CONFIG" 65 | f"__{x}" 66 | f"={plugin_env.salt_config.__getattribute__(x)}", 67 | plugin_env.salt_config.__fields__.keys() 68 | ) 69 | ) 70 | plugin_env_text += "\n" 71 | plugin_env_text += "\n".join( 72 | map( 73 | lambda x: f"{plugin_env.Config.env_prefix.upper()}" 74 | "DEVICE_CONFIG" 75 | f"__{x}" 76 | f"={plugin_env.device_config.__getattribute__(x)}", 77 | plugin_env.device_config.__fields__.keys() 78 | ) 79 | ) 80 | logger.warning( 81 | f"PluginEnv 会从 nonebot 项目目录下读取 {plugin_env.Config.env_file} 文件," 82 | "为了防止影响到其他配置,转换后的环境变量将直接输出," 83 | f"如果需要请手动复制并粘贴至 {plugin_env.Config.env_file} 文件\n{plugin_env_text}" 84 | ) 85 | 86 | # 备份V2配置文件 87 | for path in plugin_config_path, plugin_data_path: 88 | if path.exists() and path.is_file(): 89 | backup_path = path.parent / f"{path.name}.bak" 90 | path.rename(backup_path) 91 | logger.warning(f"已存在的V2版本文件已备份至 {backup_path}") 92 | 93 | write_success = True 94 | 95 | try: 96 | str_data = plugin_config_v2.json(indent=4) 97 | with open(plugin_config_path, "w", encoding="utf-8") as f: 98 | f.write(str_data) 99 | except (AttributeError, TypeError, ValueError, PermissionError): 100 | logger.exception(f"创建转换后的插件配置文件失败,请检查是否有权限读取和写入 {plugin_config_path}") 101 | write_success = False 102 | 103 | try: 104 | str_data = plugin_data_v2.json(indent=4) 105 | with open(plugin_data_path, "w", encoding="utf-8") as f: 106 | f.write(str_data) 107 | except (AttributeError, TypeError, ValueError, PermissionError): 108 | logger.exception(f"创建转换后的插件数据文件失败,请检查是否有权限读取和写入 {plugin_config_path}") 109 | write_success = False 110 | 111 | if write_success: 112 | backup_path = plugin_data_path_v1.parent / f"{plugin_data_path_v1.name}.bak" 113 | plugin_data_path_v1.rename(backup_path) 114 | logger.warning(f"原先的V1版本插件数据文件已备份至 {backup_path}") 115 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/model/upgrade/configV2.py: -------------------------------------------------------------------------------- 1 | """ 2 | 仅供从 V1 版本转换用,不应包含 V1 版本中不存在的字段 3 | """ 4 | 5 | import os 6 | from datetime import time, timedelta, datetime 7 | from pathlib import Path 8 | from typing import Union, Optional, Tuple, Any, Dict, TYPE_CHECKING 9 | 10 | from nonebot.log import logger 11 | from pydantic import BaseModel, BaseSettings, validator 12 | 13 | from ...model.common import data_path 14 | 15 | if TYPE_CHECKING: 16 | IntStr = Union[int, str] 17 | 18 | __all__ = ["plugin_config_path", "Preference", 19 | "GoodListImageConfig", "SaltConfig", "DeviceConfig", "PluginConfig", "PluginEnv"] 20 | 21 | plugin_config_path = data_path / "configV2.json" 22 | """插件数据文件默认路径""" 23 | 24 | 25 | class Preference(BaseModel): 26 | """ 27 | 偏好设置 28 | """ 29 | github_proxy: Optional[str] = "https://ghproxy.com/" 30 | """GitHub加速代理 最终会拼接在原GitHub链接前面""" 31 | enable_connection_test: bool = True 32 | """是否开启连接测试""" 33 | connection_test_interval: Optional[float] = 30 34 | """连接测试间隔(单位:秒)""" 35 | timeout: float = 10 36 | """网络请求超时时间(单位:秒)""" 37 | max_retry_times: Optional[int] = 3 38 | """最大网络请求重试次数""" 39 | retry_interval: float = 2 40 | """网络请求重试间隔(单位:秒)(除兑换请求外)""" 41 | timezone: Optional[str] = "Asia/Shanghai" 42 | """兑换时所用的时区""" 43 | exchange_thread_count: int = 2 44 | """兑换线程数""" 45 | exchange_latency: Tuple[float, float] = (0, 0.5) 46 | """同一线程下,每个兑换请求之间的间隔时间""" 47 | exchange_duration: float = 5 48 | """兑换持续时间随机范围(单位:秒)""" 49 | enable_log_output: bool = True 50 | """是否保存日志""" 51 | log_head: str = "" 52 | '''日志开头字符串(只有把插件放进plugins目录手动加载时才需要设置)''' 53 | log_path: Optional[Path] = data_path / "mystool.log" 54 | """日志保存路径""" 55 | log_rotation: Union[str, int, time, timedelta] = "1 week" 56 | '''日志保留时长(需要按照格式设置)''' 57 | plugin_name: str = "nonebot_plugin_mystool" 58 | '''插件名(为模块名字,或于plugins目录手动加载时的目录名)''' 59 | encoding: str = "utf-8" 60 | '''文件读写编码''' 61 | max_user: int = 0 62 | '''支持最多用户数''' 63 | add_friend_accept: bool = True 64 | '''是否自动同意好友申请''' 65 | add_friend_welcome: bool = True 66 | '''用户添加机器人为好友以后,是否发送使用指引信息''' 67 | command_start: str = "" 68 | '''插件内部命令头(若为""空字符串则不启用)''' 69 | sleep_time: float = 2 70 | '''任务操作冷却时间(如米游币任务)''' 71 | plan_time: str = "00:30" 72 | '''每日自动签到和米游社任务的定时任务执行时间,格式为HH:MM''' 73 | resin_interval: int = 60 74 | '''每次检查原神便笺间隔,单位为分钟''' 75 | geetest_url: Optional[str] 76 | '''极验Geetest人机验证打码接口URL''' 77 | geetest_params: Optional[Dict[str, Any]] = None 78 | '''极验Geetest人机验证打码API发送的参数(除gt,challenge外)''' 79 | geetest_json: Optional[Dict[str, Any]] = { 80 | "gt": "{gt}", 81 | "challenge": "{challenge}" 82 | } 83 | '''极验Geetest人机验证打码API发送的JSON数据 `{gt}`, `{challenge}` 为占位符''' 84 | override_device_and_salt: bool = False 85 | """是否读取插件数据文件中的 device_config 设备配置 和 salt_config 配置而不是默认配置(一般情况不建议开启)""" 86 | enable_blacklist: bool = False 87 | """是否启用用户黑名单""" 88 | blacklist_path: Optional[Path] = data_path / "blacklist.txt" 89 | """用户黑名单文件路径""" 90 | enable_whitelist: bool = False 91 | """是否启用用户白名单""" 92 | whitelist_path: Optional[Path] = data_path / "whitelist.txt" 93 | """用户白名单文件路径""" 94 | enable_admin_list: bool = False 95 | """是否启用管理员名单""" 96 | admin_list_path: Optional[Path] = data_path / "admin_list.txt" 97 | """管理员名单文件路径""" 98 | 99 | @validator("log_path", allow_reuse=True) 100 | def _(cls, v: Optional[Path]): 101 | absolute_path = v.absolute() 102 | if not os.path.exists(absolute_path) or not os.path.isfile(absolute_path): 103 | absolute_parent = absolute_path.parent 104 | try: 105 | os.makedirs(absolute_parent, exist_ok=True) 106 | except PermissionError: 107 | logger.warning(f"程序没有创建日志目录 {absolute_parent} 的权限") 108 | elif not os.access(absolute_path, os.W_OK): 109 | logger.warning(f"程序没有写入日志文件 {absolute_path} 的权限") 110 | return v 111 | 112 | @property 113 | def notice_time(self) -> bool: 114 | now_time = datetime.now().time() 115 | note_time = datetime.strptime(self.plan_time, "%H:%M") 116 | note_time = note_time + timedelta(hours=1) 117 | return now_time > note_time.time() 118 | 119 | 120 | class GoodListImageConfig(BaseModel): 121 | """ 122 | 商品列表输出图片设置 123 | """ 124 | ICON_SIZE: Tuple[int, int] = (600, 600) 125 | '''商品预览图在最终结果图中的大小''' 126 | WIDTH: int = 2000 127 | '''最终结果图宽度''' 128 | PADDING_ICON: int = 0 129 | '''展示图与展示图之间的间隙 高''' 130 | PADDING_TEXT_AND_ICON_Y: int = 125 131 | '''文字顶部与展示图顶部之间的距离 高''' 132 | PADDING_TEXT_AND_ICON_X: int = 10 133 | '''文字与展示图之间的横向距离 宽''' 134 | FONT_PATH: Union[Path, str, None] = None 135 | ''' 136 | 字体文件路径(若使用计算机已经安装的字体,直接填入字体名称,若为None则自动下载字体) 137 | 138 | 开源字体 Source Han Sans 思源黑体 139 | https://github.com/adobe-fonts/source-han-sans 140 | ''' 141 | FONT_SIZE: int = 50 142 | '''字体大小''' 143 | SAVE_PATH: Path = data_path 144 | '''商品列表图片缓存目录''' 145 | MULTI_PROCESS: bool = True 146 | '''是否使用多进程生成图片(如果生成图片时崩溃,可尝试关闭此选项)''' 147 | 148 | 149 | class SaltConfig(BaseModel): 150 | """ 151 | 生成Headers - DS所用salt值,非必要请勿修改 152 | """ 153 | SALT_IOS: str = "9ttJY72HxbjwWRNHJvn0n2AYue47nYsK" 154 | '''LK2 - 生成Headers iOS DS所需的salt''' 155 | SALT_ANDROID: str = "BIPaooxbWZW02fGHZL1If26mYCljPgst" 156 | '''K2 - 生成Headers Android DS所需的salt''' 157 | SALT_DATA: str = "t0qEgfub6cvueAPgR5m9aQWWVciEer7v" 158 | '''6X - Android 设备传入content生成 DS 所需的 salt''' 159 | SALT_PARAMS: str = "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs" 160 | '''4X - Android 设备传入url参数生成 DS 所需的 salt''' 161 | SALT_PROD: str = "JwYDpKvLj6MrMqqYU6jTKF17KNO2PXoS" 162 | '''PROD - 账号相关''' 163 | 164 | class Config(Preference.Config): 165 | pass 166 | 167 | 168 | class DeviceConfig(BaseModel): 169 | """ 170 | 设备信息 171 | Headers所用的各种数据,非必要请勿修改 172 | """ 173 | USER_AGENT_MOBILE: str = ("Mozilla/5.0 (iPhone; CPU iPhone OS 15_4 like Mac OS X) " 174 | "AppleWebKit/605.1.15 (KHTML, like Gecko) miHoYoBBS/2.55.1") 175 | '''移动端 User-Agent(Mozilla UA)''' 176 | USER_AGENT_PC: str = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " 177 | "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15") 178 | '''桌面端 User-Agent(Mozilla UA)''' 179 | USER_AGENT_OTHER: str = "Hyperion/275 CFNetwork/1402.0.8 Darwin/22.2.0" 180 | '''获取用户 ActionTicket 时Headers所用的 User-Agent''' 181 | USER_AGENT_ANDROID: str = ("Mozilla/5.0 (Linux; Android 11; MI 8 SE Build/RQ3A.211001.001; wv) " 182 | "AppleWebKit/537.36 (KHTML, like Gecko) " 183 | "Version/4.0 Chrome/104.0.5112.97 Mobile Safari/537.36 miHoYoBBS/2.55.1") 184 | '''安卓端 User-Agent(Mozilla UA)''' 185 | USER_AGENT_ANDROID_OTHER: str = "okhttp/4.9.3" 186 | '''安卓端 User-Agent(专用于米游币任务等)''' 187 | USER_AGENT_WIDGET: str = "WidgetExtension/231 CFNetwork/1390 Darwin/22.0.0" 188 | '''iOS 小组件 User-Agent(原神实时便笺)''' 189 | 190 | X_RPC_DEVICE_MODEL_MOBILE: str = "iPhone10,2" 191 | '''移动端 x-rpc-device_model''' 192 | X_RPC_DEVICE_MODEL_PC: str = "OS X 10.15.7" 193 | '''桌面端 x-rpc-device_model''' 194 | X_RPC_DEVICE_MODEL_ANDROID: str = "MI 8 SE" 195 | '''安卓端 x-rpc-device_model''' 196 | 197 | X_RPC_DEVICE_NAME_MOBILE: str = "iPhone" 198 | '''移动端 x-rpc-device_name''' 199 | X_RPC_DEVICE_NAME_PC: str = "Microsoft Edge 103.0.1264.62" 200 | '''桌面端 x-rpc-device_name''' 201 | X_RPC_DEVICE_NAME_ANDROID: str = "Xiaomi MI 8 SE" 202 | '''安卓端 x-rpc-device_name''' 203 | 204 | X_RPC_SYS_VERSION: str = "16.2" 205 | '''Headers所用的 x-rpc-sys_version''' 206 | X_RPC_SYS_VERSION_ANDROID: str = "11" 207 | '''安卓端 x-rpc-sys_version''' 208 | 209 | X_RPC_CHANNEL: str = "appstore" 210 | '''Headers所用的 x-rpc-channel''' 211 | X_RPC_CHANNEL_ANDROID: str = "miyousheluodi" 212 | '''安卓端 x-rpc-channel''' 213 | 214 | X_RPC_APP_VERSION: str = "2.63.1" 215 | '''Headers所用的 x-rpc-app_version''' 216 | X_RPC_PLATFORM: str = "ios" 217 | '''Headers所用的 x-rpc-platform''' 218 | UA: str = "\".Not/A)Brand\";v=\"99\", \"Microsoft Edge\";v=\"103\", \"Chromium\";v=\"103\"" 219 | '''Headers所用的 sec-ch-ua''' 220 | UA_PLATFORM: str = "\"macOS\"" 221 | '''Headers所用的 sec-ch-ua-platform''' 222 | 223 | class Config(Preference.Config): 224 | pass 225 | 226 | 227 | class PluginConfig(BaseSettings): 228 | preference = Preference() 229 | good_list_image_config = GoodListImageConfig() 230 | 231 | 232 | class PluginEnv(BaseSettings): 233 | salt_config = SaltConfig() 234 | device_config = DeviceConfig() 235 | 236 | class Config(BaseSettings.Config): 237 | env_prefix = "mystool_" 238 | env_file = '.env' 239 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/model/upgrade/dataV2.py: -------------------------------------------------------------------------------- 1 | """ 2 | 仅供从 V1 版本转换用,不应包含 V1 版本中不存在的字段 3 | """ 4 | 5 | import json 6 | from json import JSONDecodeError 7 | from typing import Union, Optional, Any, Dict, TYPE_CHECKING, AbstractSet, \ 8 | Mapping, Set, Literal, List 9 | from uuid import UUID, uuid4 10 | 11 | from httpx import Cookies 12 | from nonebot.log import logger 13 | from pydantic import BaseModel, ValidationError, validator 14 | 15 | from ..._version import __version__ 16 | from ...model.common import data_path, BaseModelWithSetter, Address, BaseModelWithUpdate, Good, GameRecord 17 | 18 | if TYPE_CHECKING: 19 | IntStr = Union[int, str] 20 | DictStrAny = Dict[str, Any] 21 | AbstractSetIntStr = AbstractSet[IntStr] 22 | MappingIntStrAny = Mapping[IntStr, Any] 23 | 24 | __all__ = ["plugin_data_path", "BBSCookies", "UserAccount", "ExchangePlan", "ExchangeResult", "uuid4_validate", 25 | "UserData", "PluginData", "PluginDataManager"] 26 | 27 | plugin_data_path = data_path / "dataV2.json" 28 | _uuid_set: Set[str] = set() 29 | """已使用的用户UUID密钥集合""" 30 | _new_uuid_in_init = False 31 | """插件反序列化用户数据时,是否生成了新的UUID密钥""" 32 | 33 | 34 | class BBSCookies(BaseModelWithSetter, BaseModelWithUpdate): 35 | """ 36 | 米游社Cookies数据 37 | 38 | # 测试 is_correct() 方法 39 | 40 | >>> assert BBSCookies().is_correct() is False 41 | >>> assert BBSCookies(stuid="123", stoken="123", cookie_token="123").is_correct() is True 42 | 43 | # 测试 bbs_uid getter 44 | 45 | >>> bbs_cookies = BBSCookies() 46 | >>> assert not bbs_cookies.bbs_uid 47 | >>> assert BBSCookies(stuid="123").bbs_uid == "123" 48 | 49 | # 测试 bbs_uid setter 50 | 51 | >>> bbs_cookies.bbs_uid = "123" 52 | >>> assert bbs_cookies.bbs_uid == "123" 53 | 54 | # 检查构造函数内所用的 stoken setter 55 | 56 | >>> bbs_cookies = BBSCookies(stoken="abcd1234") 57 | >>> assert bbs_cookies.stoken_v1 and not bbs_cookies.stoken_v2 58 | >>> bbs_cookies = BBSCookies(stoken="v2_abcd1234==") 59 | >>> assert bbs_cookies.stoken_v2 and not bbs_cookies.stoken_v1 60 | >>> assert bbs_cookies.stoken == "v2_abcd1234==" 61 | 62 | # 检查 stoken setter 63 | 64 | >>> bbs_cookies = BBSCookies(stoken="abcd1234") 65 | >>> bbs_cookies.stoken = "v2_abcd1234==" 66 | >>> assert bbs_cookies.stoken_v2 == "v2_abcd1234==" 67 | >>> assert bbs_cookies.stoken_v1 == "abcd1234" 68 | 69 | # 检查 .dict 方法能否生成包含 stoken_2 类型的 stoken 的字典 70 | 71 | >>> bbs_cookies = BBSCookies() 72 | >>> bbs_cookies.stoken_v1 = "abcd1234" 73 | >>> bbs_cookies.stoken_v2 = "v2_abcd1234==" 74 | >>> assert bbs_cookies.dict(v2_stoken=True)["stoken"] == "v2_abcd1234==" 75 | 76 | # 检查是否有多余的字段 77 | 78 | >>> bbs_cookies = BBSCookies(stuid="123") 79 | >>> assert all(bbs_cookies.dict()) 80 | >>> assert all(map(lambda x: x not in bbs_cookies, ["stoken_v1", "stoken_v2"])) 81 | 82 | # 测试 update 方法 83 | 84 | >>> bbs_cookies = BBSCookies(stuid="123") 85 | >>> assert bbs_cookies.update({"stuid": "456", "stoken": "abc"}) is bbs_cookies 86 | >>> assert bbs_cookies.stuid == "456" 87 | >>> assert bbs_cookies.stoken == "abc" 88 | 89 | >>> bbs_cookies = BBSCookies(stuid="123") 90 | >>> new_cookies = BBSCookies(stuid="456", stoken="abc") 91 | >>> assert bbs_cookies.update(new_cookies) is bbs_cookies 92 | >>> assert bbs_cookies.stuid == "456" 93 | >>> assert bbs_cookies.stoken == "abc" 94 | """ 95 | stuid: Optional[str] 96 | """米游社UID""" 97 | ltuid: Optional[str] 98 | """米游社UID""" 99 | account_id: Optional[str] 100 | """米游社UID""" 101 | login_uid: Optional[str] 102 | """米游社UID""" 103 | 104 | stoken_v1: Optional[str] 105 | """保存stoken_v1,方便后续使用""" 106 | stoken_v2: Optional[str] 107 | """保存stoken_v2,方便后续使用""" 108 | 109 | cookie_token: Optional[str] 110 | login_ticket: Optional[str] 111 | ltoken: Optional[str] 112 | mid: Optional[str] 113 | 114 | def __init__(self, **data: Any): 115 | super().__init__(**data) 116 | stoken = data.get("stoken") 117 | if stoken: 118 | self.stoken = stoken 119 | 120 | def is_correct(self) -> bool: 121 | """判断是否为正确的Cookies""" 122 | if self.bbs_uid and self.stoken and self.cookie_token: 123 | return True 124 | else: 125 | return False 126 | 127 | @property 128 | def bbs_uid(self): 129 | """ 130 | 获取米游社UID 131 | """ 132 | uid = None 133 | for value in [self.stuid, self.ltuid, self.account_id, self.login_uid]: 134 | if value: 135 | uid = value 136 | break 137 | return uid or None 138 | 139 | @bbs_uid.setter 140 | def bbs_uid(self, value: str): 141 | self.stuid = value 142 | self.ltuid = value 143 | self.account_id = value 144 | self.login_uid = value 145 | 146 | @property 147 | def stoken(self): 148 | """ 149 | 获取stoken 150 | :return: 优先返回 self.stoken_v1 151 | """ 152 | if self.stoken_v1: 153 | return self.stoken_v1 154 | elif self.stoken_v2: 155 | return self.stoken_v2 156 | else: 157 | return None 158 | 159 | @stoken.setter 160 | def stoken(self, value): 161 | if value.startswith("v2_"): 162 | self.stoken_v2 = value 163 | else: 164 | self.stoken_v1 = value 165 | 166 | def update(self, cookies: Union[Dict[str, str], Cookies, "BBSCookies"]): 167 | """ 168 | 更新Cookies 169 | """ 170 | if not isinstance(cookies, BBSCookies): 171 | self.stoken = cookies.get("stoken") or self.stoken 172 | self.bbs_uid = cookies.get("bbs_uid") or self.bbs_uid 173 | cookies.pop("stoken", None) 174 | cookies.pop("bbs_uid", None) 175 | return super().update(cookies) 176 | 177 | def dict(self, *, 178 | include: Optional[Union['AbstractSetIntStr', 'MappingIntStrAny']] = None, 179 | exclude: Optional[Union['AbstractSetIntStr', 'MappingIntStrAny']] = None, 180 | by_alias: bool = False, 181 | skip_defaults: Optional[bool] = None, exclude_unset: bool = False, exclude_defaults: bool = False, 182 | exclude_none: bool = False, v2_stoken: bool = False, 183 | cookie_type: bool = False) -> 'DictStrAny': 184 | """ 185 | 获取Cookies字典 186 | 187 | v2_stoken: stoken 字段是否使用 stoken_v2 188 | cookie_type: 是否返回符合Cookie类型的字典(没有自定义的stoken_v1、stoken_v2键) 189 | """ 190 | # 保证 stuid, ltuid 等字段存在 191 | self.bbs_uid = self.bbs_uid 192 | cookies_dict = super().dict(include=include, exclude=exclude, by_alias=by_alias, skip_defaults=skip_defaults, 193 | exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, 194 | exclude_none=exclude_none) 195 | if v2_stoken and self.stoken_v2: 196 | cookies_dict["stoken"] = self.stoken_v2 197 | else: 198 | cookies_dict["stoken"] = self.stoken_v1 199 | 200 | if cookie_type: 201 | # 去除自定义的 stoken_v1, stoken_v2 字段 202 | cookies_dict.pop("stoken_v1") 203 | cookies_dict.pop("stoken_v2") 204 | 205 | # 去除空的字段 206 | empty_key = set() 207 | for key, value in cookies_dict.items(): 208 | if not value: 209 | empty_key.add(key) 210 | [cookies_dict.pop(key) for key in empty_key] 211 | 212 | return cookies_dict 213 | 214 | 215 | class UserAccount(BaseModelWithSetter): 216 | """ 217 | 米游社账户数据 218 | 219 | >>> user_account = UserAccount( 220 | >>> cookies=BBSCookies(), 221 | >>> device_id_ios="DBB8886C-C88A-4E12-A407-BE295E95E084", 222 | >>> device_id_android="64561CE4-5F43-41D7-B92F-41CEFABC7ABF" 223 | >>> ) 224 | >>> assert isinstance(user_account, UserAccount) 225 | >>> user_account.bbs_uid = "123" 226 | >>> assert user_account.bbs_uid == "123" 227 | """ 228 | phone_number: Optional[str] 229 | """手机号""" 230 | cookies: BBSCookies 231 | """Cookies""" 232 | address: Optional[Address] 233 | """收货地址""" 234 | 235 | device_id_ios: str 236 | """iOS设备用 deviceID""" 237 | device_id_android: str 238 | """安卓设备用 deviceID""" 239 | device_fp: Optional[str] 240 | """iOS设备用 deviceFp""" 241 | 242 | enable_mission: bool = True 243 | '''是否开启米游币任务计划''' 244 | enable_game_sign: bool = True 245 | '''是否开启米游社游戏签到计划''' 246 | enable_resin: bool = True 247 | '''是否开启便笺提醒''' 248 | platform: Literal["ios", "android"] = "ios" 249 | '''设备平台''' 250 | mission_games: List[str] = [] 251 | '''在哪些板块执行米游币任务计划 为 BaseMission 子类名称''' 252 | user_stamina_threshold: int = 240 253 | '''崩铁便笺体力提醒阈值,0为一直提醒''' 254 | user_resin_threshold: int = 200 255 | '''原神便笺树脂提醒阈值,0为一直提醒''' 256 | 257 | def __init__(self, **data: Any): 258 | if not data.get("device_id_ios") or not data.get("device_id_android"): 259 | from ...utils import generate_device_id 260 | if not data.get("device_id_ios"): 261 | data.setdefault("device_id_ios", generate_device_id()) 262 | if not data.get("device_id_android"): 263 | data.setdefault("device_id_android", generate_device_id()) 264 | 265 | super().__init__(**data) 266 | 267 | @property 268 | def bbs_uid(self): 269 | """ 270 | 获取米游社UID 271 | """ 272 | return self.cookies.bbs_uid 273 | 274 | @bbs_uid.setter 275 | def bbs_uid(self, value: str): 276 | self.cookies.bbs_uid = value 277 | 278 | 279 | class ExchangePlan(BaseModel): 280 | """ 281 | 兑换计划数据类 282 | """ 283 | 284 | good: Good 285 | """商品""" 286 | address: Optional[Address] 287 | """地址ID""" 288 | account: UserAccount 289 | """米游社账号""" 290 | game_record: Optional[GameRecord] 291 | """商品对应的游戏的玩家账号""" 292 | 293 | def __hash__(self): 294 | return hash( 295 | ( 296 | self.good.goods_id, 297 | self.good.time, 298 | self.address.id if self.address else None, 299 | self.account.bbs_uid, 300 | self.game_record.game_role_id if self.game_record else None 301 | ) 302 | ) 303 | 304 | class CustomDict(dict): 305 | _hash: int 306 | 307 | def __hash__(self): 308 | return self._hash 309 | 310 | def dict( 311 | self, 312 | *, 313 | include: Optional[Union['AbstractSetIntStr', 'MappingIntStrAny']] = None, 314 | exclude: Optional[Union['AbstractSetIntStr', 'MappingIntStrAny']] = None, 315 | by_alias: bool = False, 316 | skip_defaults: Optional[bool] = None, 317 | exclude_unset: bool = False, 318 | exclude_defaults: bool = False, 319 | exclude_none: bool = False, 320 | ) -> 'DictStrAny': 321 | """ 322 | 重写 dict 方法,使其返回的 dict 可以被 hash 323 | """ 324 | normal_dict = super().dict(include=include, exclude=exclude, by_alias=by_alias, skip_defaults=skip_defaults, 325 | exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, 326 | exclude_none=exclude_none) 327 | hashable_dict = ExchangePlan.CustomDict(normal_dict) 328 | hashable_dict._hash = hash(self) 329 | return hashable_dict 330 | 331 | 332 | class ExchangeResult(BaseModel): 333 | """ 334 | 兑换结果数据类 335 | """ 336 | result: bool 337 | """兑换结果""" 338 | return_data: dict 339 | """返回数据""" 340 | plan: ExchangePlan 341 | """兑换计划""" 342 | 343 | 344 | def uuid4_validate(v): 345 | """ 346 | 验证UUID是否为合法的UUIDv4 347 | 348 | :param v: UUID 349 | """ 350 | try: 351 | UUID(v, version=4) 352 | except Exception: 353 | return False 354 | else: 355 | return True 356 | 357 | 358 | class UserData(BaseModelWithSetter): 359 | """ 360 | 用户数据类 361 | 362 | >>> userdata = UserData() 363 | >>> hash(userdata) 364 | """ 365 | enable_notice: bool = True 366 | """是否开启通知""" 367 | uuid: Optional[str] = None 368 | """用户UUID密钥,用于不同NoneBot适配器平台之间的数据同步,因此不可泄露""" 369 | qq_guilds: Optional[Dict[str, Set[int]]] = {} 370 | """储存用户所在的QQ频道ID {用户ID : [频道ID]}""" 371 | exchange_plans: Union[Set[ExchangePlan], List[ExchangePlan]] = set() 372 | """兑换计划列表""" 373 | accounts: Dict[str, UserAccount] = {} 374 | """储存一些已绑定的账号数据""" 375 | 376 | @validator("uuid") 377 | def uuid_validator(cls, v): 378 | """ 379 | 验证UUID是否为合法的UUIDv4 380 | 381 | :raises ValueError: UUID格式错误,不是合法的UUIDv4 382 | """ 383 | if v is None and not uuid4_validate(v): 384 | raise ValueError("UUID格式错误,不是合法的UUIDv4") 385 | 386 | def __init__(self, **data: Any): 387 | global _new_uuid_in_init 388 | super().__init__(**data) 389 | 390 | exchange_plans = self.exchange_plans 391 | self.exchange_plans = set() 392 | for plan in exchange_plans: 393 | plan = ExchangePlan.parse_obj(plan) 394 | self.exchange_plans.add(plan) 395 | 396 | if self.uuid is None: 397 | new_uuid = uuid4() 398 | while str(new_uuid) in _uuid_set: 399 | new_uuid = uuid4() 400 | self.uuid = str(new_uuid) 401 | _new_uuid_in_init = True 402 | _uuid_set.add(self.uuid) 403 | 404 | def __hash__(self): 405 | return hash(self.uuid) 406 | 407 | 408 | class PluginData(BaseModel): 409 | version: str = __version__ 410 | """创建插件数据文件时的版本号""" 411 | user_bind: Optional[Dict[str, str]] = {} 412 | '''不同NoneBot适配器平台的用户数据绑定关系(如QQ聊天和QQ频道)(空用户数据:被绑定用户数据)''' 413 | users: Dict[str, UserData] = {} 414 | '''所有用户数据''' 415 | 416 | def do_user_bind(self, src: str = None, dst: str = None, write: bool = False): 417 | """ 418 | 执行用户数据绑定同步,将src指向dst的用户数据,即src处的数据将会被dst处的数据对象替换 419 | 420 | :param src: 源用户数据,为空则读取 self.user_bind 并执行全部绑定 421 | :param dst: 目标用户数据,为空则读取 self.user_bind 并执行全部绑定 422 | :param write: 是否写入插件数据文件 423 | """ 424 | if None in [src, dst]: 425 | for x, y in self.user_bind.items(): 426 | try: 427 | self.users[x] = self.users[y] 428 | except KeyError: 429 | logger.error(f"用户数据绑定失败,目标用户 {y} 不存在") 430 | else: 431 | try: 432 | self.user_bind[src] = dst 433 | self.users[src] = self.users[dst] 434 | except KeyError: 435 | logger.error(f"用户数据绑定失败,目标用户 {dst} 不存在") 436 | else: 437 | if write: 438 | PluginDataManager.write_plugin_data() 439 | 440 | def __init__(self, **data: Any): 441 | super().__init__(**data) 442 | self.do_user_bind(write=True) 443 | 444 | class Config: 445 | json_encoders = UserAccount.Config.json_encoders 446 | 447 | 448 | class PluginDataManager: 449 | plugin_data: Optional[PluginData] = None 450 | """加载出的插件数据对象""" 451 | 452 | @classmethod 453 | def load_plugin_data(cls): 454 | """ 455 | 加载插件数据文件 456 | """ 457 | if plugin_data_path.exists() and plugin_data_path.is_file(): 458 | try: 459 | with open(plugin_data_path, "r") as f: 460 | plugin_data_dict = json.load(f) 461 | # 读取完整的插件数据 462 | cls.plugin_data = PluginData.parse_obj(plugin_data_dict) 463 | except (ValidationError, JSONDecodeError): 464 | logger.exception(f"读取插件数据文件失败,请检查插件数据文件 {plugin_data_path} 格式是否正确") 465 | raise 466 | except Exception: 467 | logger.exception( 468 | f"读取插件数据文件失败,请检查插件数据文件 {plugin_data_path} 是否存在且有权限读取和写入") 469 | raise 470 | else: 471 | cls.plugin_data = PluginData() 472 | try: 473 | str_data = cls.plugin_data.json(indent=4) 474 | with open(plugin_data_path, "w", encoding="utf-8") as f: 475 | f.write(str_data) 476 | except (AttributeError, TypeError, ValueError, PermissionError): 477 | logger.exception(f"创建插件数据文件失败,请检查是否有权限读取和写入 {plugin_data_path}") 478 | raise 479 | else: 480 | logger.info(f"插件数据文件 {plugin_data_path} 不存在,已创建默认插件数据文件。") 481 | 482 | @classmethod 483 | def write_plugin_data(cls): 484 | """ 485 | 写入插件数据文件 486 | 487 | :return: 是否成功 488 | """ 489 | try: 490 | str_data = cls.plugin_data.json(indent=4) 491 | except (AttributeError, TypeError, ValueError): 492 | logger.exception("数据对象序列化失败,可能是数据类型错误") 493 | return False 494 | else: 495 | with open(plugin_data_path, "w", encoding="utf-8") as f: 496 | f.write(str_data) 497 | return True 498 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .common import * 2 | from .good_image import * 3 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/utils/common.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import io 3 | import json 4 | import os 5 | import random 6 | import string 7 | import time 8 | import uuid 9 | from copy import deepcopy 10 | from pathlib import Path 11 | from typing import (Dict, Literal, 12 | Union, Optional, Tuple, Iterable, List) 13 | from urllib.parse import urlencode 14 | 15 | import httpx 16 | import nonebot.log 17 | import nonebot.plugin 18 | import tenacity 19 | 20 | try: 21 | from loguru import Logger 22 | except ImportError: 23 | Logger = None 24 | pass 25 | 26 | from nonebot import Adapter, Bot 27 | 28 | from nonebot_plugin_saa import MessageSegmentFactory, Text, AggregatedMessageFactory, TargetQQPrivate, \ 29 | TargetQQGuildDirect, enable_auto_select_bot 30 | 31 | from nonebot.adapters.onebot.v11 import MessageEvent as OneBotV11MessageEvent, PrivateMessageEvent, GroupMessageEvent, \ 32 | Adapter as OneBotV11Adapter, Bot as OneBotV11Bot 33 | from nonebot.adapters.qq import DirectMessageCreateEvent, MessageCreateEvent, \ 34 | Adapter as QQGuildAdapter, Bot as QQGuildBot, MessageEvent 35 | from nonebot.log import logger 36 | from nonebot.log import logger 37 | from qrcode import QRCode 38 | 39 | from ..model import GeetestResult, PluginDataManager, Preference, plugin_config, plugin_env, UserData 40 | 41 | __all__ = ["GeneralMessageEvent", "GeneralPrivateMessageEvent", "GeneralGroupMessageEvent", "CommandBegin", 42 | "get_last_command_sep", "COMMAND_BEGIN", "set_logger", "logger", "PLUGIN", "custom_attempt_times", 43 | "get_async_retry", "generate_device_id", "cookie_str_to_dict", "cookie_dict_to_str", "generate_ds", 44 | "get_validate", "generate_seed_id", "generate_fp_locally", "get_file", "blur_phone", "generate_qr_img", 45 | "send_private_msg", "get_unique_users", "get_all_bind", "read_blacklist", "read_whitelist", 46 | "read_admin_list"] 47 | 48 | # 启用 nonebot-plugin-send-anything-anywhere 的自动选择 Bot 功能 49 | enable_auto_select_bot() 50 | 51 | GeneralMessageEvent = OneBotV11MessageEvent, MessageCreateEvent, DirectMessageCreateEvent, MessageEvent 52 | """消息事件类型""" 53 | GeneralPrivateMessageEvent = PrivateMessageEvent, DirectMessageCreateEvent 54 | """私聊消息事件类型""" 55 | GeneralGroupMessageEvent = GroupMessageEvent, MessageCreateEvent 56 | """群聊消息事件类型""" 57 | 58 | 59 | class CommandBegin: 60 | """ 61 | 命令开头字段 62 | (包括例如'/'和插件命令起始字段例如'mystool') 63 | 已重写__str__方法 64 | """ 65 | string = "" 66 | '''命令开头字段(包括例如'/'和插件命令起始字段例如'mystool')''' 67 | 68 | @classmethod 69 | def set_command_begin(cls): 70 | """ 71 | 机器人启动时设置命令开头字段 72 | """ 73 | if nonebot.get_driver().config.command_start: 74 | cls.string = list(nonebot.get_driver().config.command_start)[0] + plugin_config.preference.command_start 75 | else: 76 | cls.string = plugin_config.preference.command_start 77 | 78 | @classmethod 79 | def __str__(cls): 80 | return cls.string 81 | 82 | 83 | def get_last_command_sep(): 84 | """ 85 | 获取第最后一个命令分隔符 86 | """ 87 | if nonebot.get_driver().config.command_sep: 88 | return list(nonebot.get_driver().config.command_sep)[-1] 89 | 90 | 91 | COMMAND_BEGIN = CommandBegin() 92 | '''命令开头字段(包括例如'/'和插件命令起始字段例如'mystool')''' 93 | 94 | 95 | def set_logger(_logger: "Logger"): 96 | """ 97 | 给日志记录器对象增加输出到文件的Handler 98 | """ 99 | # 根据"name"筛选日志,如果在 plugins 目录加载,则通过 LOG_HEAD 识别 100 | # 如果不是插件输出的日志,但是与插件有关,则也进行保存 101 | logger.add( 102 | plugin_config.preference.log_path, 103 | diagnose=False, 104 | format=nonebot.log.default_format, 105 | rotation=plugin_config.preference.log_rotation, 106 | filter=lambda x: x["name"] == plugin_config.preference.plugin_name or ( 107 | plugin_config.preference.log_head != "" and x["message"].find(plugin_config.preference.log_head) == 0 108 | ) or x["message"].find(f"plugins.{plugin_config.preference.plugin_name}") != -1 109 | ) 110 | 111 | return logger 112 | 113 | 114 | logger = set_logger(logger) 115 | """本插件所用日志记录器对象(包含输出到文件)""" 116 | 117 | PLUGIN = nonebot.plugin.get_plugin(plugin_config.preference.plugin_name) 118 | '''本插件数据''' 119 | 120 | if not PLUGIN: 121 | logger.warning( 122 | "插件数据(Plugin)获取失败,如果插件是从本地加载的,需要修改配置文件中 PLUGIN_NAME 为插件目录,否则将导致无法获取插件帮助信息等") 123 | 124 | 125 | def custom_attempt_times(retry: bool): 126 | """ 127 | 自定义的重试机制停止条件\n 128 | 根据是否要重试的bool值,给出相应的`tenacity.stop_after_attempt`对象 129 | 130 | :param retry True - 重试次数达到配置中 MAX_RETRY_TIMES 时停止; False - 执行次数达到1时停止,即不进行重试 131 | """ 132 | if retry: 133 | return tenacity.stop_after_attempt(plugin_config.preference.max_retry_times + 1) 134 | else: 135 | return tenacity.stop_after_attempt(1) 136 | 137 | 138 | def get_async_retry(retry: bool): 139 | """ 140 | 获取异步重试装饰器 141 | 142 | :param retry: True - 重试次数达到偏好设置中 max_retry_times 时停止; False - 执行次数达到1时停止,即不进行重试 143 | """ 144 | return tenacity.AsyncRetrying( 145 | stop=custom_attempt_times(retry), 146 | retry=tenacity.retry_if_exception_type(BaseException), 147 | wait=tenacity.wait_fixed(plugin_config.preference.retry_interval), 148 | ) 149 | 150 | 151 | def generate_device_id() -> str: 152 | """ 153 | 生成随机的x-rpc-device_id 154 | """ 155 | return str(uuid.uuid4()).upper() 156 | 157 | 158 | def cookie_str_to_dict(cookie_str: str) -> Dict[str, str]: 159 | """ 160 | 将字符串Cookie转换为字典Cookie 161 | """ 162 | cookie_str = cookie_str.replace(" ", "") 163 | # Cookie末尾缺少 ; 的情况 164 | if cookie_str[-1] != ";": 165 | cookie_str += ";" 166 | 167 | cookie_dict = {} 168 | start = 0 169 | while start != len(cookie_str): 170 | mid = cookie_str.find("=", start) 171 | end = cookie_str.find(";", mid) 172 | cookie_dict.setdefault(cookie_str[start:mid], cookie_str[mid + 1:end]) 173 | start = end + 1 174 | return cookie_dict 175 | 176 | 177 | def cookie_dict_to_str(cookie_dict: Dict[str, str]) -> str: 178 | """ 179 | 将字符串Cookie转换为字典Cookie 180 | """ 181 | cookie_str = "" 182 | for key in cookie_dict: 183 | cookie_str += (key + "=" + cookie_dict[key] + ";") 184 | return cookie_str 185 | 186 | 187 | def generate_ds(data: Union[str, dict, list, None] = None, params: Union[str, dict, None] = None, 188 | platform: Literal["ios", "android"] = "ios", salt: Optional[str] = None): 189 | """ 190 | 获取Headers中所需DS 191 | 192 | :param data: 可选,网络请求中需要发送的数据 193 | :param params: 可选,URL参数 194 | :param platform: 可选,平台,ios或android 195 | :param salt: 可选,自定义salt 196 | """ 197 | if data is None and params is None or \ 198 | salt is not None and salt != plugin_env.salt_config.SALT_PROD: 199 | if platform == "ios": 200 | salt = salt or plugin_env.salt_config.SALT_IOS 201 | else: 202 | salt = salt or plugin_env.salt_config.SALT_ANDROID 203 | t = str(int(time.time())) 204 | a = "".join(random.sample( 205 | string.ascii_lowercase + string.digits, 6)) 206 | re = hashlib.md5( 207 | f"salt={salt}&t={t}&r={a}".encode()).hexdigest() 208 | return f"{t},{a},{re}" 209 | else: 210 | if params: 211 | salt = plugin_env.salt_config.SALT_PARAMS if not salt else salt 212 | else: 213 | salt = plugin_env.salt_config.SALT_DATA if not salt else salt 214 | 215 | if not data: 216 | if salt == plugin_env.salt_config.SALT_PROD: 217 | data = {} 218 | else: 219 | data = "" 220 | if not params: 221 | params = "" 222 | 223 | if not isinstance(data, str): 224 | data = json.dumps(data).replace(" ", "") 225 | if not isinstance(params, str): 226 | params = urlencode(params) 227 | 228 | t = str(int(time.time())) 229 | r = str(random.randint(100000, 200000)) 230 | c = hashlib.md5( 231 | f"salt={salt}&t={t}&r={r}&b={data}&q={params}".encode()).hexdigest() 232 | return f"{t},{r},{c}" 233 | 234 | 235 | async def get_validate(user: UserData, gt: str = None, challenge: str = None): 236 | """ 237 | 使用打码平台获取人机验证validate 238 | 239 | :param user: 用户数据对象 240 | :param gt: 验证码gt 241 | :param challenge: challenge 242 | :return: 如果配置了平台URL,且 gt, challenge 不为空,返回 GeetestResult 243 | """ 244 | if not plugin_config.preference.global_geetest: 245 | if not (gt and challenge) or not user.geetest_url: 246 | return GeetestResult("", "") 247 | geetest_url = user.geetest_url 248 | params = {"gt": gt, "challenge": challenge} 249 | params.update(user.geetest_params or {}) 250 | else: 251 | if not (gt and challenge) or not plugin_config.preference.geetest_url: 252 | return GeetestResult("", "") 253 | geetest_url = plugin_config.preference.geetest_url 254 | params = {"gt": gt, "challenge": challenge} 255 | params.update(plugin_config.preference.geetest_params or {}) 256 | content = deepcopy(plugin_config.preference.geetest_json or Preference().geetest_json) 257 | for key, value in content.items(): 258 | if isinstance(value, str): 259 | content[key] = value.format(gt=gt, challenge=challenge) 260 | debug_log = {"geetest_url": geetest_url, "params": params, "content": content} 261 | logger.debug(f"{plugin_config.preference.log_head}get_validate: {debug_log}") 262 | try: 263 | async with httpx.AsyncClient() as client: 264 | res = await client.post( 265 | geetest_url, 266 | params=params, 267 | json=content, 268 | timeout=60 269 | ) 270 | geetest_data = res.json() 271 | logger.debug(f"{plugin_config.preference.log_head}人机验证结果:{geetest_data}") 272 | validate = geetest_data['data']['validate'] 273 | seccode = geetest_data['data'].get('seccode') or f"{validate}|jordan" 274 | return GeetestResult(validate=validate, seccode=seccode) 275 | except Exception: 276 | logger.exception(f"{plugin_config.preference.log_head}获取人机验证validate失败") 277 | 278 | 279 | def generate_seed_id(length: int = 8) -> str: 280 | """ 281 | 生成随机的 seed_id(即长度为8的十六进制数) 282 | 283 | :param length: 16进制数长度 284 | """ 285 | max_num = int("FF" * length, 16) 286 | return hex(random.randint(0, max_num))[2:] 287 | 288 | 289 | def generate_fp_locally(length: int = 13): 290 | """ 291 | 于本地生成 device_fp 292 | 293 | :param length: device_fp 长度 294 | """ 295 | characters = string.digits + "abcdef" 296 | return ''.join(random.choices(characters, k=length)) 297 | 298 | 299 | async def get_file(url: str, retry: bool = True): 300 | """ 301 | 下载文件 302 | 303 | :param url: 文件URL 304 | :param retry: 是否允许重试 305 | :return: 文件数据,若下载失败则返回 ``None`` 306 | """ 307 | try: 308 | async for attempt in get_async_retry(retry): 309 | with attempt: 310 | async with httpx.AsyncClient() as client: 311 | res = await client.get(url, timeout=plugin_config.preference.timeout, follow_redirects=True) 312 | return res.content 313 | except tenacity.RetryError: 314 | logger.exception(f"{plugin_config.preference.log_head}下载文件 - {url} 失败") 315 | return None 316 | 317 | 318 | def blur_phone(phone: Union[str, int]) -> str: 319 | """ 320 | 模糊手机号 321 | 322 | :param phone: 手机号 323 | :return: 模糊后的手机号 324 | """ 325 | if isinstance(phone, int): 326 | phone = str(phone) 327 | return f"☎️{phone[-4:]}" 328 | 329 | 330 | def generate_qr_img(data: str): 331 | """ 332 | 生成二维码图片 333 | 334 | :param data: 二维码数据 335 | 336 | >>> b = generate_qr_img("https://github.com/Ljzd-PRO/nonebot-plugin-mystool") 337 | >>> isinstance(b, bytes) 338 | """ 339 | qr_code = QRCode(border=2) 340 | qr_code.add_data(data) 341 | qr_code.make() 342 | image = qr_code.make_image() 343 | image_bytes = io.BytesIO() 344 | image.save(image_bytes) 345 | return image_bytes.getvalue() 346 | 347 | 348 | async def send_private_msg( 349 | user_id: str, 350 | message: Union[str, MessageSegmentFactory, AggregatedMessageFactory], 351 | use: Union[Bot, Adapter] = None, 352 | guild_id: int = None 353 | ) -> Tuple[bool, Optional[Exception]]: 354 | """ 355 | 主动发送私信消息 356 | 357 | :param user_id: 目标用户ID 358 | :param message: 消息内容 359 | :param use: 使用的Bot或Adapter,为None则使用所有Bot 360 | :param guild_id: 用户所在频道ID,为None则从用户数据中获取 361 | :return: (是否发送成功, ActionFailed Exception) 362 | """ 363 | user_id_int = int(user_id) 364 | if isinstance(message, str): 365 | message = Text(message) 366 | 367 | # 整合符合条件的 Bot 对象 368 | if isinstance(use, (OneBotV11Bot, QQGuildBot)): 369 | bots = [use] 370 | elif isinstance(use, (OneBotV11Adapter, QQGuildAdapter)): 371 | bots = use.bots.values() 372 | else: 373 | bots = nonebot.get_bots().values() 374 | 375 | for bot in bots: 376 | try: 377 | # 获取 PlatformTarget 对象 378 | if isinstance(bot, OneBotV11Bot): 379 | target = TargetQQPrivate(user_id=user_id_int) 380 | logger.info( 381 | f"{plugin_config.preference.log_head}向用户 {user_id} 发送 QQ 聊天私信 user_id: {user_id_int}") 382 | else: 383 | if guild_id is None: 384 | if user := PluginDataManager.plugin_data.users.get(user_id): 385 | if not (guild_id := user.qq_guild.get(user_id)): 386 | logger.error(f"{plugin_config.preference.log_head}用户 {user_id} 数据中没有任何频道ID") 387 | return False, None 388 | else: 389 | logger.error( 390 | f"{plugin_config.preference.log_head}用户数据中不存在用户 {user_id},无法获取频道ID") 391 | return False, None 392 | target = TargetQQGuildDirect(recipient_id=user_id_int, source_guild_id=guild_id) 393 | logger.info(f"{plugin_config.preference.log_head}向用户 {user_id} 发送 QQ 频道私信" 394 | f" recipient_id: {user_id_int}, source_guild_id: {guild_id}") 395 | 396 | await message.send_to(target=target, bot=bot) 397 | except Exception as e: 398 | return False, e 399 | else: 400 | return True, None 401 | 402 | 403 | def get_unique_users() -> Iterable[Tuple[str, UserData]]: 404 | """ 405 | 获取 不包含绑定用户数据 的所有用户数据以及对应的ID,即不会出现值重复项 406 | 407 | :return: dict_items[用户ID, 用户数据] 408 | """ 409 | return filter(lambda x: x[0] not in PluginDataManager.plugin_data.user_bind, 410 | PluginDataManager.plugin_data.users.items()) 411 | 412 | 413 | def get_all_bind(user_id: str) -> Iterable[str]: 414 | """ 415 | 获取绑定该用户的所有用户ID 416 | 417 | :return: 绑定该用户的所有用户ID 418 | """ 419 | user_id_filter = filter(lambda x: PluginDataManager.plugin_data.user_bind.get(x) == user_id, 420 | PluginDataManager.plugin_data.user_bind) 421 | return user_id_filter 422 | 423 | 424 | def _read_user_list(path: Path) -> List[str]: 425 | """ 426 | 从TEXT读取用户名单 427 | 428 | :return: 名单中的所有用户ID 429 | """ 430 | if not path: 431 | return [] 432 | if os.path.isfile(path): 433 | with open(path, "r", encoding=plugin_config.preference.encoding) as f: 434 | lines = f.readlines() 435 | lines = map(lambda x: x.strip(), lines) 436 | line_filter = filter(lambda x: x and x != "\n", lines) 437 | return list(line_filter) 438 | else: 439 | logger.error(f"{plugin_config.preference.log_head}黑/白名单文件 {path} 不存在") 440 | return [] 441 | 442 | 443 | def read_blacklist() -> List[str]: 444 | """ 445 | 读取黑名单 446 | 447 | :return: 黑名单中的所有用户ID 448 | """ 449 | return _read_user_list(plugin_config.preference.blacklist_path) if plugin_config.preference.enable_blacklist else [] 450 | 451 | 452 | def read_whitelist() -> List[str]: 453 | """ 454 | 读取白名单 455 | 456 | :return: 白名单中的所有用户ID 457 | """ 458 | return _read_user_list(plugin_config.preference.whitelist_path) if plugin_config.preference.enable_whitelist else [] 459 | 460 | 461 | def read_admin_list() -> List[str]: 462 | """ 463 | 读取白名单 464 | 465 | :return: 管理员名单中的所有用户ID 466 | """ 467 | return _read_user_list( 468 | plugin_config.preference.admin_list_path) if plugin_config.preference.enable_admin_list else [] 469 | -------------------------------------------------------------------------------- /src/nonebot_plugin_mystool/utils/good_image.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import zipfile 4 | from multiprocessing import Lock 5 | from typing import List, Tuple 6 | 7 | import httpx 8 | from PIL import Image, ImageDraw, ImageFont 9 | 10 | from ..api.common import get_good_detail 11 | from ..model import Good, data_path, plugin_config 12 | from ..utils.common import get_file, logger, get_async_retry 13 | 14 | __all__ = ["game_list_to_image"] 15 | 16 | FONT_URL = os.path.join( 17 | plugin_config.preference.github_proxy, 18 | "https://github.com/adobe-fonts/source-han-sans/releases/download/2.004R/SourceHanSansHWSC.zip") 19 | TEMP_FONT_PATH = data_path / "temp" / "font.zip" 20 | FONT_SAVE_PATH = data_path / "SourceHanSansHWSC-Regular.otf" 21 | 22 | 23 | async def game_list_to_image(good_list: List[Good], lock: Lock = None, retry: bool = True): 24 | """ 25 | 将商品信息列表转换为图片数据,若返回`None`说明生成失败 26 | 27 | :param good_list: 商品列表数据 28 | :param lock: 进程同步锁,防止多进程同时在下载字体 29 | :param retry: 是否允许重试 30 | """ 31 | # TODO: 暂时会阻塞,目前还找不到更好的解决方案 32 | # 回调函数是否适用于 NoneBot Matcher 暂不清楚, 33 | # 若适用则可以传入回调函数而不阻塞主进程 34 | try: 35 | if lock is not None: 36 | lock.acquire() 37 | 38 | font_path = plugin_config.good_list_image_config.FONT_PATH 39 | if font_path is None or not os.path.isfile(font_path): 40 | if os.path.isfile(FONT_SAVE_PATH): 41 | font_path = FONT_SAVE_PATH 42 | else: 43 | logger.warning( 44 | f"{plugin_config.preference.log_head}商品列表图片生成 - 缺少字体,正在从 " 45 | "https://github.com/adobe-fonts/source-han-sans/tree/release " 46 | f"下载字体...") 47 | try: 48 | os.makedirs(os.path.dirname(TEMP_FONT_PATH)) 49 | except FileExistsError: 50 | pass 51 | with open(TEMP_FONT_PATH, "wb") as f: 52 | content = await get_file(FONT_URL) 53 | if content is None: 54 | logger.error( 55 | f"{plugin_config.preference.log_head}商品列表图片生成 - 字体下载失败,无法继续生成图片") 56 | return None 57 | f.write(content) 58 | with open(TEMP_FONT_PATH, "rb") as f: 59 | with zipfile.ZipFile(f) as z: 60 | with z.open("OTF/SimplifiedChineseHW/SourceHanSansHWSC-Regular.otf") as zip_font: 61 | with open(FONT_SAVE_PATH, "wb") as fp_font: 62 | fp_font.write(zip_font.read()) 63 | logger.info( 64 | f"{plugin_config.preference.log_head}商品列表图片生成 - 已完成字体下载 -> {FONT_SAVE_PATH}") 65 | try: 66 | os.remove(TEMP_FONT_PATH) 67 | except Exception: 68 | logger.exception( 69 | f"{plugin_config.preference.log_head}商品列表图片生成 - 无法清理下载的字体压缩包临时文件") 70 | font_path = FONT_SAVE_PATH 71 | 72 | if lock is not None: 73 | lock.release() 74 | 75 | font = ImageFont.truetype( 76 | str(font_path), plugin_config.good_list_image_config.FONT_SIZE, encoding=plugin_config.preference.encoding) 77 | 78 | size_y = 0 79 | '''起始粘贴位置 高''' 80 | position: List[Tuple[int, int]] = [] 81 | '''预览图粘贴的位置''' 82 | imgs: List[Image.Image] = [] 83 | '''商品预览图''' 84 | 85 | for good in good_list: 86 | await get_good_detail(good) 87 | async for attempt in get_async_retry(retry): 88 | with attempt: 89 | async with httpx.AsyncClient() as client: 90 | icon = await client.get(good.icon, timeout=plugin_config.preference.timeout) 91 | img = Image.open(io.BytesIO(icon.content)) 92 | # 调整预览图大小 93 | img = img.resize(plugin_config.good_list_image_config.ICON_SIZE) 94 | # 记录预览图粘贴位置 95 | position.append((0, size_y)) 96 | # 调整下一个粘贴的位置 97 | size_y += plugin_config.good_list_image_config.ICON_SIZE[1] + \ 98 | plugin_config.good_list_image_config.PADDING_ICON 99 | imgs.append(img) 100 | 101 | preview = Image.new( 102 | 'RGB', (plugin_config.good_list_image_config.WIDTH, size_y), (255, 255, 255)) 103 | 104 | i = 0 105 | for img in imgs: 106 | preview.paste(img, position[i]) 107 | i += 1 108 | 109 | draw_y = plugin_config.good_list_image_config.PADDING_TEXT_AND_ICON_Y 110 | '''写入文字的起始位置 高''' 111 | for good in good_list: 112 | draw = ImageDraw.Draw(preview) 113 | # 根据预览图高度来确定写入文字的位置,并调整空间 114 | draw.text((plugin_config.good_list_image_config.ICON_SIZE[ 115 | 0] + plugin_config.good_list_image_config.PADDING_TEXT_AND_ICON_X, 116 | draw_y), 117 | f"{good.general_name}\n商品ID: {good.goods_id}\n兑换时间: {good.time_text}\n价格: {good.price} 米游币", 118 | (0, 0, 0), font) 119 | draw_y += (plugin_config.good_list_image_config.ICON_SIZE[1] + 120 | plugin_config.good_list_image_config.PADDING_ICON) 121 | 122 | # 导出 123 | image_bytes = io.BytesIO() 124 | preview.save(image_bytes, format="JPEG") 125 | return image_bytes.getvalue() 126 | except Exception: 127 | logger.exception(f"{plugin_config.preference.log_head}商品列表图片生成 - 无法完成图片生成") 128 | -------------------------------------------------------------------------------- /subscribe/config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "time": 1677178168, 4 | "version": [ 5 | "v0.2.0", 6 | "v0.2.1", 7 | "v0.2.2-beta.1", 8 | "v0.2.2-rc.1", 9 | "v0.2.2-rc.2", 10 | "v0.2.2", 11 | "v0.2.3" 12 | ], 13 | "config": { 14 | "Config": { 15 | "SALT_IOS": "ulInCDohgEs557j0VsPDYnQaaz6KJcv5", 16 | "SALT_ANDROID": "n0KjuIrKgLHh08LWSCYP0WXlVXaYvV64", 17 | "SALT_DATA": "t0qEgfub6cvueAPgR5m9aQWWVciEer7v", 18 | "SALT_PARAMS": "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs" 19 | }, 20 | "DeviceConfig": { 21 | "USER_AGENT_MOBILE": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) miHoYoBBS/2.28.1", 22 | "USER_AGENT_ANDROID": "Mozilla/5.0 (Linux; Android 11; MI 8 SE Build/RQ3A.211001.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/104.0.5112.97 Mobile Safari/537.36 miHoYoBBS/2.28.1", 23 | "X_RPC_APP_VERSION": "2.28.1" 24 | } 25 | } 26 | }, 27 | { 28 | "time": 1671719967, 29 | "version": [ 30 | "v0.2.0", 31 | "v0.2.1", 32 | "v0.2.2-beta.1", 33 | "v0.2.2-rc.1", 34 | "v0.2.2-rc.2", 35 | "v0.2.2", 36 | "v0.2.3" 37 | ], 38 | "config": { 39 | "Config": { 40 | "SALT_IOS": "YVEIkzDFNHLeKXLxzqCA9TzxCpWwbIbk", 41 | "SALT_ANDROID": "n0KjuIrKgLHh08LWSCYP0WXlVXaYvV64", 42 | "SALT_DATA": "t0qEgfub6cvueAPgR5m9aQWWVciEer7v", 43 | "SALT_PARAMS": "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs" 44 | }, 45 | "DeviceConfig": { 46 | "USER_AGENT_MOBILE": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) miHoYoBBS/2.36.1", 47 | "USER_AGENT_ANDROID": "Mozilla/5.0 (Linux; Android 11; MI 8 SE Build/RQ3A.211001.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/104.0.5112.97 Mobile Safari/537.36 miHoYoBBS/2.36.1" 48 | } 49 | } 50 | } 51 | ] 52 | -------------------------------------------------------------------------------- /subscribe/config_0.2.4_up.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "time": 1677178168, 4 | "version": [ 5 | "v0.2.0", 6 | "v0.2.1", 7 | "v0.2.2-beta.1", 8 | "v0.2.2-rc.1", 9 | "v0.2.2-rc.2", 10 | "v0.2.2", 11 | "v0.2.3" 12 | ], 13 | "config": { 14 | "Config": { 15 | "SALT_IOS": "ulInCDohgEs557j0VsPDYnQaaz6KJcv5", 16 | "SALT_ANDROID": "n0KjuIrKgLHh08LWSCYP0WXlVXaYvV64", 17 | "SALT_DATA": "t0qEgfub6cvueAPgR5m9aQWWVciEer7v", 18 | "SALT_PARAMS": "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs" 19 | }, 20 | "DeviceConfig": { 21 | "USER_AGENT_MOBILE": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) miHoYoBBS/2.28.1", 22 | "USER_AGENT_ANDROID": "Mozilla/5.0 (Linux; Android 11; MI 8 SE Build/RQ3A.211001.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/104.0.5112.97 Mobile Safari/537.36 miHoYoBBS/2.28.1", 23 | "X_RPC_APP_VERSION": "2.28.1" 24 | } 25 | } 26 | }, 27 | { 28 | "time": 1671719967, 29 | "version": [ 30 | "v0.2.0", 31 | "v0.2.1", 32 | "v0.2.2-beta.1", 33 | "v0.2.2-rc.1", 34 | "v0.2.2-rc.2", 35 | "v0.2.2", 36 | "v0.2.3" 37 | ], 38 | "config": { 39 | "Config": { 40 | "SALT_IOS": "YVEIkzDFNHLeKXLxzqCA9TzxCpWwbIbk", 41 | "SALT_ANDROID": "n0KjuIrKgLHh08LWSCYP0WXlVXaYvV64", 42 | "SALT_DATA": "t0qEgfub6cvueAPgR5m9aQWWVciEer7v", 43 | "SALT_PARAMS": "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs" 44 | }, 45 | "DeviceConfig": { 46 | "USER_AGENT_MOBILE": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) miHoYoBBS/2.36.1", 47 | "USER_AGENT_ANDROID": "Mozilla/5.0 (Linux; Android 11; MI 8 SE Build/RQ3A.211001.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/104.0.5112.97 Mobile Safari/537.36 miHoYoBBS/2.36.1" 48 | } 49 | } 50 | }, 51 | { 52 | "time": 1679153988, 53 | "version": [ 54 | "v0.2.4" 55 | ], 56 | "config": { 57 | "ENCODING": "utf-8", 58 | "MAX_USER": 10, 59 | "ADD_FRIEND_ACCEPT": true, 60 | "ADD_FRIEND_WELCOME": true, 61 | "COMMAND_START": "", 62 | "PLUGIN_NAME": "nonebot_plugin_mystool", 63 | "LOG_HEAD": "", 64 | "LOG_SAVE": true, 65 | "LOG_PATH": "/home/ljzd/Python_Programs/mystool/data/nonebot-plugin-mystool/mystool.log", 66 | "LOG_ROTATION": "1 week", 67 | "NTP_SERVER": "ntp.aliyun.com", 68 | "MAX_RETRY_TIMES": 5, 69 | "SLEEP_TIME": 5, 70 | "SLEEP_TIME_RETRY": 3, 71 | "TIME_OUT": null, 72 | "GITHUB_PROXY": "https://ghproxy.com/", 73 | "SIGN_TIME": "00:30", 74 | "RESIN_CHECK_INTERVAL": 60, 75 | "EXCHANGE_THREAD": 3, 76 | "SALT_IOS": "ulInCDohgEs557j0VsPDYnQaaz6KJcv5", 77 | "SALT_ANDROID": "n0KjuIrKgLHh08LWSCYP0WXlVXaYvV64", 78 | "SALT_DATA": "t0qEgfub6cvueAPgR5m9aQWWVciEer7v", 79 | "SALT_PARAMS": "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs", 80 | "device": { 81 | "USER_AGENT_MOBILE": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) miHoYoBBS/2.42.1", 82 | "USER_AGENT_PC": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15", 83 | "USER_AGENT_OTHER": "Hyperion/275 CFNetwork/1402.0.8 Darwin/22.2.0", 84 | "USER_AGENT_ANDROID": "Mozilla/5.0 (Linux; Android 11; MI 8 SE Build/RQ3A.211001.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/104.0.5112.97 Mobile Safari/537.36 miHoYoBBS/2.36.1", 85 | "USER_AGENT_ANDROID_OTHER": "okhttp/4.9.3", 86 | "USER_AGENT_WIDGET": "WidgetExtension/231 CFNetwork/1390 Darwin/22.0.0", 87 | "X_RPC_DEVICE_MODEL_MOBILE": "iPhone10,2", 88 | "X_RPC_DEVICE_MODEL_PC": "OS X 10.15.7", 89 | "X_RPC_DEVICE_MODEL_ANDROID": "MI 8 SE", 90 | "X_RPC_DEVICE_NAME_MOBILE": "iPhone", 91 | "X_RPC_DEVICE_NAME_PC": "Microsoft Edge 103.0.1264.62", 92 | "X_RPC_DEVICE_NAME_ANDROID": "Xiaomi MI 8 SE", 93 | "X_RPC_SYS_VERSION": "15.4", 94 | "X_RPC_SYS_VERSION_ANDROID": "11", 95 | "X_RPC_CHANNEL": "appstore", 96 | "X_RPC_CHANNEL_ANDROID": "miyousheluodi", 97 | "X_RPC_APP_VERSION": "2.28.1", 98 | "X_RPC_PLATFORM": "ios", 99 | "UA": "\".Not/A)Brand\";v=\"99\", \"Microsoft Edge\";v=\"103\", \"Chromium\";v=\"103\"", 100 | "UA_PLATFORM": "\"macOS\"" 101 | }, 102 | "goodListImage": { 103 | "ICON_SIZE": [ 104 | 600, 105 | 600 106 | ], 107 | "WIDTH": 2000, 108 | "PADDING_ICON": 0, 109 | "PADDING_TEXT_AND_ICON_Y": 125, 110 | "PADDING_TEXT_AND_ICON_X": 10, 111 | "FONT_PATH": null, 112 | "FONT_SIZE": 50, 113 | "SAVE_PATH": "/home/ljzd/Python_Programs/mystool/data/nonebot-plugin-mystool" 114 | } 115 | } 116 | } 117 | ] 118 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ljzd-PRO/nonebot-plugin-mystool/e79d3c3926cb559b51db143e30bab2bfbc93936a/tests/__init__.py -------------------------------------------------------------------------------- /tests/plugin_test.py: -------------------------------------------------------------------------------- 1 | """插件加载测试 2 | 3 | 测试代码修改自 ,谢谢 [Lan 佬](https://github.com/Lancercmd)。 4 | 5 | 在 GitHub Actions 中运行,通过 GitHub Event 文件获取所需信息。并将测试结果保存至 GitHub Action 的输出文件中。 6 | 7 | 当前会输出 RESULT, OUTPUT, METADATA 三个数据,分别对应测试结果、测试输出、插件元数据。 8 | 9 | 经测试可以直接在 Python 3.10+ 环境下运行,无需额外依赖。 10 | """ 11 | # ruff: noqa: T201, ASYNC101 12 | 13 | import asyncio 14 | import os 15 | import re 16 | from asyncio import create_subprocess_shell, run, subprocess 17 | from pathlib import Path 18 | from typing import Optional, List, Dict 19 | 20 | FAKE_SCRIPT = """from typing import Optional, Union 21 | 22 | from nonebot import logger 23 | from nonebot.drivers import ( 24 | ASGIMixin, 25 | HTTPClientMixin, 26 | HTTPClientSession, 27 | HTTPVersion, 28 | Request, 29 | Response, 30 | WebSocketClientMixin, 31 | ) 32 | from nonebot.drivers import Driver as BaseDriver 33 | from nonebot.internal.driver.model import ( 34 | CookieTypes, 35 | HeaderTypes, 36 | QueryTypes, 37 | ) 38 | from typing_extensions import override 39 | 40 | 41 | class Driver(BaseDriver, ASGIMixin, HTTPClientMixin, WebSocketClientMixin): 42 | @property 43 | @override 44 | def type(self) -> str: 45 | return "fake" 46 | 47 | @property 48 | @override 49 | def logger(self): 50 | return logger 51 | 52 | @override 53 | def run(self, *args, **kwargs): 54 | super().run(*args, **kwargs) 55 | 56 | @property 57 | @override 58 | def server_app(self): 59 | return None 60 | 61 | @property 62 | @override 63 | def asgi(self): 64 | raise NotImplementedError 65 | 66 | @override 67 | def setup_http_server(self, setup): 68 | raise NotImplementedError 69 | 70 | @override 71 | def setup_websocket_server(self, setup): 72 | raise NotImplementedError 73 | 74 | @override 75 | async def request(self, setup: Request) -> Response: 76 | raise NotImplementedError 77 | 78 | @override 79 | async def websocket(self, setup: Request) -> Response: 80 | raise NotImplementedError 81 | 82 | @override 83 | def get_session( 84 | self, 85 | params: QueryTypes = None, 86 | headers: HeaderTypes = None, 87 | cookies: CookieTypes = None, 88 | version: Union[str, HTTPVersion] = HTTPVersion.H11, 89 | timeout: Optional[float] = None, 90 | proxy: Optional[str] = None, 91 | ) -> HTTPClientSession: 92 | raise NotImplementedError 93 | """ 94 | 95 | RUNNER_SCRIPT = """import json 96 | import os 97 | 98 | from nonebot import init, load_plugin, logger, require 99 | from pydantic import BaseModel 100 | 101 | 102 | class SetEncoder(json.JSONEncoder): 103 | def default(self, obj): 104 | if isinstance(obj, set): 105 | return list(obj) 106 | return json.JSONEncoder.default(self, obj) 107 | 108 | 109 | init() 110 | plugin = load_plugin("{}") 111 | 112 | if not plugin: 113 | exit(1) 114 | else: 115 | if plugin.metadata: 116 | metadata = {{ 117 | "name": plugin.metadata.name, 118 | "description": plugin.metadata.description, 119 | "usage": plugin.metadata.usage, 120 | "type": plugin.metadata.type, 121 | "homepage": plugin.metadata.homepage, 122 | "supported_adapters": plugin.metadata.supported_adapters, 123 | }} 124 | with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf8") as f: 125 | f.write(f"METADATA< str: 136 | """去除 ANSI 转义字符""" 137 | if not text: 138 | return "" 139 | ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") 140 | return ansi_escape.sub("", text) 141 | 142 | 143 | class PluginTest: 144 | def __init__( 145 | self, module_name: str, config: str = None, python: str = ">=3.9,<4.0" 146 | ) -> None: 147 | self.module_name = module_name 148 | self.config = config 149 | self.python = python 150 | self._plugin_list = None 151 | 152 | self._create = False 153 | self._run = False 154 | self._deps = [] 155 | 156 | # 输出信息 157 | self._output_lines: List[str] = [] 158 | 159 | # 插件测试目录 160 | self.test_dir = Path("plugin_test") 161 | # 通过环境变量获取 GITHUB 输出文件位置 162 | self.github_output_file = Path(os.environ.get("GITHUB_OUTPUT", "")) 163 | self.github_step_summary_file = Path(os.environ.get("GITHUB_STEP_SUMMARY", "")) 164 | 165 | @property 166 | def path(self) -> Path: 167 | """插件测试目录""" 168 | return self.test_dir / f"{self.module_name}-test" 169 | 170 | async def run(self): 171 | # 运行前创建测试目录 172 | if not self.test_dir.exists(): 173 | self.test_dir.mkdir() 174 | 175 | await self.create_poetry_project() 176 | if self._create: 177 | await self.run_poetry_project() 178 | 179 | # 输出测试结果 180 | with open(self.github_output_file, "a", encoding="utf8") as f: 181 | f.write(f"RESULT={self._run}\n") 182 | # 输出测试输出 183 | output = "\n".join(self._output_lines) 184 | # GitHub 不支持 ANSI 转义字符所以去掉 185 | ansiless_output = strip_ansi(output) 186 | # 限制输出长度,防止评论过长,评论最大长度为 65536 187 | ansiless_output = ansiless_output[:50000] 188 | with open(self.github_output_file, "a", encoding="utf8") as f: 189 | f.write(f"OUTPUT<测试输出
{ansiless_output}
" 194 | f.write(f"{summary}") 195 | return self._run, output 196 | 197 | @staticmethod 198 | def get_env() -> Dict[str, str]: 199 | """获取环境变量""" 200 | env = os.environ.copy() 201 | # 启用 LOGURU 的颜色输出 202 | env["LOGURU_COLORIZE"] = "true" 203 | return env 204 | 205 | async def create_poetry_project(self) -> None: 206 | if not self.path.exists(): 207 | self.path.mkdir(parents=True) 208 | proc = await create_subprocess_shell( 209 | f'poetry init --name=plugin-test --python="{self.python}" -n', 210 | stdout=subprocess.PIPE, 211 | stderr=subprocess.PIPE, 212 | cwd=self.path, 213 | env=self.get_env(), 214 | ) 215 | stdout, stderr = await proc.communicate() 216 | code = proc.returncode 217 | 218 | self._create = not code 219 | if self._create: 220 | print(f"项目 {self.module_name} 创建成功。") 221 | for i in stdout.decode().strip().splitlines(): 222 | print(f" {i}") 223 | else: 224 | self._log_output(f"项目 {self.module_name} 创建失败:") 225 | for i in stderr.decode().strip().splitlines(): 226 | self._log_output(f" {i}") 227 | else: 228 | self._log_output(f"项目 {self.module_name} 已存在,跳过创建。") 229 | self._create = True 230 | 231 | async def run_poetry_project(self) -> None: 232 | if self.path.exists(): 233 | # 默认使用 fake 驱动 234 | with open(self.path / ".env", "w", encoding="utf8") as f: 235 | f.write("DRIVER=fake") 236 | # 如果提供了插件配置项,则写入配置文件 237 | if self.config is not None: 238 | with open(self.path / ".env.prod", "w", encoding="utf8") as f: 239 | f.write(self.config) 240 | 241 | with open(self.path / "fake.py", "w", encoding="utf8") as f: 242 | f.write(FAKE_SCRIPT) 243 | 244 | with open(self.path / "runner.py", "w", encoding="utf8") as f: 245 | f.write( 246 | RUNNER_SCRIPT.format( 247 | self.module_name, 248 | "\n".join([f"require('{i}')" for i in self._deps]), 249 | ) 250 | ) 251 | 252 | proc = None 253 | try: 254 | proc = await create_subprocess_shell( 255 | "poetry run python runner.py", 256 | stdout=subprocess.PIPE, 257 | stderr=subprocess.PIPE, 258 | cwd=self.path, 259 | env=self.get_env(), 260 | ) 261 | stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=600) 262 | code = proc.returncode 263 | except asyncio.TimeoutError: 264 | if proc: 265 | proc.terminate() 266 | stdout = b"" 267 | stderr = "测试超时".encode() 268 | code = 1 269 | 270 | self._run = not code 271 | 272 | status = "正常" if self._run else "出错" 273 | self._log_output(f"插件 {self.module_name} 加载{status}:") 274 | 275 | _out = stdout.decode().strip().splitlines() 276 | _err = stderr.decode().strip().splitlines() 277 | for i in _out: 278 | self._log_output(f" {i}") 279 | for i in _err: 280 | self._log_output(f" {i}") 281 | 282 | def _log_output(self, output: str) -> None: 283 | """记录输出,同时打印到控制台""" 284 | print(output) 285 | self._output_lines.append(output) 286 | 287 | 288 | async def main(): 289 | # 测试插件 290 | test = PluginTest( 291 | "nonebot_plugin_mystool" 292 | ) 293 | return await test.run() 294 | 295 | 296 | if __name__ == "__main__": 297 | is_success, _ = run(main()) 298 | exit(not is_success) 299 | --------------------------------------------------------------------------------