├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── actions │ └── setup-python │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── codecov.yml │ ├── pyright.yml │ └── ruff.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bot.py ├── pdm.lock ├── pyproject.toml ├── requirements.txt ├── resources ├── font │ ├── ADLaMDisplay-Regular.ttf │ ├── SpicyRice-Regular.ttf │ └── baotu.ttf ├── image │ └── status │ │ ├── background.png │ │ └── marker.png └── logo.jpg ├── sora ├── __init__.py ├── config │ ├── __init__.py │ ├── config.py │ ├── path.py │ └── utils.py ├── database │ ├── __init__.py │ └── models │ │ ├── __init__.py │ │ ├── ban.py │ │ ├── bind.py │ │ ├── item.py │ │ ├── sign.py │ │ └── user.py ├── hook.py ├── log.py ├── permission.py ├── plugins │ ├── __init__.py │ ├── admin │ │ ├── __init__.py │ │ └── utils.py │ ├── ban │ │ └── __init__.py │ ├── control │ │ ├── __init__.py │ │ └── data_source.py │ ├── echo.py │ ├── help │ │ ├── __init__.py │ │ └── config.py │ ├── hooks │ │ ├── __init__.py │ │ ├── check_ban.py │ │ ├── level_upgrade.py │ │ └── user_exist.py │ ├── network │ │ └── __init__.py │ ├── pic │ │ ├── __init__.py │ │ ├── pic_cos.py │ │ ├── pic_jk.py │ │ ├── pic_legs.py │ │ └── pic_setu.py │ ├── sign │ │ ├── __init__.py │ │ ├── config.py │ │ └── utils.py │ ├── status │ │ ├── __init__.py │ │ ├── color.py │ │ ├── drawer.py │ │ ├── model.py │ │ ├── path.py │ │ └── utils.py │ └── user │ │ ├── __init__.py │ │ └── model.py ├── utils │ ├── __init__.py │ ├── annotated.py │ ├── api.py │ ├── files.py │ ├── helpers.py │ ├── requests.py │ ├── scheduler.py │ ├── update.py │ ├── user.py │ ├── user_agent.py │ └── utils.py └── version.py └── tests └── .coveragerc /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [{*.py,*.pyi}] 15 | indent_size = 4 16 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: netsora 2 | custom: ['afdian.net/@netsora'] 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 错误报告 2 | title: "Bug: " 3 | description: 创建一个错误报告来帮助我们改进 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | ## 注意事项 10 | [GitHub Issues](../issues) 专门用于错误报告和功能需求,这意味着我们不接受使用问题。如果您打开的问题不符合要求,它将会被无条件关闭。 11 | 12 | 有关使用问题,请通过以下途径: 13 | - 阅读文档以解决 14 | - 在社区内寻求他人解答 15 | - 在 [GitHub Discussions](../discussions) 上提问 16 | - 在网络中搜索是否有人遇到过类似的问题 17 | 18 | 如果您不知道如何有效、精准地提出一个问题,我们建议您先阅读[《提问的智慧》](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md)。 19 | 20 | 最后,请记得遵守我们的社区准则,友好交流。 21 | 22 | - type: checkboxes 23 | id: terms 24 | attributes: 25 | label: 报告清单 26 | description: 请确认您已遵守所有必选项。 27 | options: 28 | - label: 我已仔细阅读并了解上述注意事项。 29 | required: true 30 | - label: 我已使用最新版本测试过,确认问题依旧存在。 31 | required: true 32 | - label: 我确定在 [GitHub Issues](../issues) 中没有相同或相似的问题。 33 | required: true 34 | - label: 我有足够的时间和能力,愿意为此提交 PR 来修复问题。 35 | 36 | - type: input 37 | id: version 38 | attributes: 39 | label: 影响版本 40 | description: 这个问题出现在哪个版本上? 41 | placeholder: 发布版本号或 Commit ID 42 | validations: 43 | required: true 44 | 45 | - type: textarea 46 | id: describe 47 | attributes: 48 | label: 问题描述 49 | description: 请清晰简洁地说明问题是什么,并解释您是如何遇到此问题的,以及您为此做出的尝试。 50 | placeholder: 我遇到的问题是…… 51 | validations: 52 | required: true 53 | 54 | - type: textarea 55 | id: reproduction 56 | attributes: 57 | label: 复现步骤 58 | description: | 59 | 提供能复现此问题的详细操作步骤。如果可能,请尝试提供一个可复现的测试用例,该测试用例是发生问题所需的最低限度。 60 | 推荐阅读:[《如何创建一个最小的、可复现的示例》](https://stackoverflow.com/help/minimal-reproducible-example) 61 | placeholder: | 62 | 1. 首先…… 63 | 2. 然后…… 64 | 3. 发生…… 65 | validations: 66 | required: true 67 | 68 | - type: textarea 69 | id: expected 70 | attributes: 71 | label: 预期行为 72 | description: 您的预期中会发生什么? 73 | placeholder: 正常情况下它应该…… 74 | 75 | - type: textarea 76 | id: logs 77 | attributes: 78 | label: 日志信息 79 | description: 提供有助于诊断问题的任何日志和完整的错误信息。 80 | placeholder: 请注意将您的敏感信息从日志中过滤或替换。 81 | 82 | - type: textarea 83 | id: context 84 | attributes: 85 | label: 额外补充 86 | description: 在此处添加相关的任何其他上下文或截图,或者您觉得有帮助的信息。 87 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 📝 文档改进 4 | url: https://github.com/netsora/SoraBot-website/issues/new?assignees=&labels=documentation&projects=&template=document.yml&title=Docs%3A+ 5 | about: 文档错误及改进意见反馈 6 | - name: SoraBot 论坛 7 | url: https://github.com/orgs/netsora/discussions 8 | about: 前往 SoraBot 论坛提问 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: ✨ 功能需求 2 | title: "Feature: " 3 | description: 为项目提出一个新的想法或建议 4 | labels: ["enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | ## 注意事项 10 | [GitHub Issues](../issues) 专门用于错误报告和功能需求,这意味着我们不接受使用问题。如果您打开的问题不符合要求,它将会被无条件关闭。 11 | 12 | 有关使用问题,请通过以下途径: 13 | - 阅读文档以解决 14 | - 在社区内寻求他人解答 15 | - 在 [GitHub Discussions](../discussions) 上提问 16 | - 在网络中搜索是否有人遇到过类似的问题 17 | 18 | 最后,请记得遵守我们的社区准则,友好交流。 19 | 20 | - type: checkboxes 21 | id: terms 22 | attributes: 23 | label: 报告清单 24 | description: 请确认您已遵守所有必选项。 25 | options: 26 | - label: 我已仔细阅读并了解上述注意事项。 27 | required: true 28 | - label: 我已使用最新版本测试过,确认功能并未实现。 29 | required: true 30 | - label: 我确定在 [GitHub Issues](../issues) 中没有相同或相似的需求。 31 | required: true 32 | - label: 我有足够的时间和能力,愿意为此提交 PR 来实现功能。 33 | 34 | - type: textarea 35 | id: problem 36 | attributes: 37 | label: 您希望能解决什么样的问题? 38 | description: 请简要地说明是什么问题导致您想要一个新功能。也许我们可以提出一种现有的解决办法。 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | id: solution 44 | attributes: 45 | label: 您想要的解决方案 46 | description: 请说明您希望使用什么样的方法解决上述问题。 47 | validations: 48 | required: true 49 | 50 | - type: textarea 51 | id: alternatives 52 | attributes: 53 | label: 您考虑过的替代方案 54 | description: 除了上述方法以外,您还考虑过哪些其他的实现方式? 55 | 56 | - type: textarea 57 | id: usecase 58 | attributes: 59 | label: 实现的功能是什么样的? 60 | description: | 61 | 提供功能在实现后如何使用的代码示例。请注意,您可以使用 Markdown 来设置代码块的格式。 62 | 尽可能多地提供细节。您希望它如何使用的示例代码会有所帮助。 63 | 64 | - type: textarea 65 | id: context 66 | attributes: 67 | label: 还有什么要补充的吗? 68 | description: 在此处添加相关的任何其他上下文或截图,或者您觉得有帮助的信息。 69 | -------------------------------------------------------------------------------- /.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.10" 9 | 10 | runs: 11 | using: "composite" 12 | steps: 13 | - name: Install Pdm 14 | run: pipx install pdm 15 | shell: bash 16 | 17 | - name: Setup PDM 18 | uses: pdm-project/setup-pdm@v3 19 | with: 20 | python-version: ${{ inputs.python-version }} 21 | architecture: "x64" 22 | cache: true 23 | 24 | - name: Install dependencies 25 | run: pdm install 26 | shell: bash 27 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: Code Coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | paths: 9 | - "sora/**" 10 | 11 | jobs: 12 | test: 13 | name: Test Coverage 14 | runs-on: ${{ matrix.os }} 15 | concurrency: 16 | group: test-coverage-${{ github.ref }}-${{ matrix.os }}-${{ matrix.python-version }} 17 | cancel-in-progress: true 18 | strategy: 19 | matrix: 20 | python-version: ["3.10", "3.11"] 21 | os: [ubuntu-latest, windows-latest, macos-latest] 22 | fail-fast: false 23 | env: 24 | OS: ${{ matrix.os }} 25 | PYTHON_VERSION: ${{ matrix.python-version }} 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Setup Python environment 31 | uses: ./.github/actions/setup-python 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | 35 | - name: Upload coverage reports to Codecov 36 | uses: codecov/codecov-action@v3 37 | env: 38 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 39 | 40 | 41 | -------------------------------------------------------------------------------- /.github/workflows/pyright.yml: -------------------------------------------------------------------------------- 1 | name: Pyright Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | paths: 9 | - "sora/**" 10 | - "tests/**" 11 | 12 | jobs: 13 | pyright: 14 | name: Pyright Lint 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Setup Python environment 20 | uses: ./.github/actions/setup-python 21 | 22 | - run: echo "$(pdm venv --path in-project)/bin" >> $GITHUB_PATH 23 | 24 | - name: Run Pyright 25 | uses: jakebailey/pyright-action@v1 26 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Ruff Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | paths: 9 | - "sora/**" 10 | - "tests/**" 11 | 12 | jobs: 13 | ruff: 14 | name: Ruff Lint 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Run Ruff Lint 20 | uses: chartboost/ruff-action@v1 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | .idea/ 163 | 164 | # ruff 165 | .ruff_cache/ 166 | 167 | data/ 168 | 169 | sora.toml 170 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_install_hook_types: [pre-commit, prepare-commit-msg] 2 | ci: 3 | autofix_commit_msg: ":rotating_light: auto fix by pre-commit hooks" 4 | autofix_prs: true 5 | autoupdate_branch: master 6 | autoupdate_schedule: monthly 7 | autoupdate_commit_msg: ":arrow_up: auto update by pre-commit hooks" 8 | repos: 9 | - repo: https://github.com/astral-sh/ruff-pre-commit 10 | rev: v0.1.9 11 | hooks: 12 | - id: ruff 13 | args: [--fix, --exit-non-zero-on-fix] 14 | stages: [commit] 15 | 16 | - repo: https://github.com/pycqa/isort 17 | rev: 5.13.2 18 | hooks: 19 | - id: isort 20 | stages: [commit] 21 | 22 | - repo: https://github.com/psf/black 23 | rev: 23.12.1 24 | hooks: 25 | - id: black 26 | stages: [commit] 27 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-python.python", 4 | "ms-python.vscode-pylance", 5 | "ms-python.black-formatter", 6 | "charliermarsh.ruff", 7 | "tamasfe.even-better-toml" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // 使用 IntelliSense 了解相关属性。 3 | // 悬停以查看现有属性的描述。 4 | // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "debugpy", 9 | "name": "Launch Program", 10 | "request": "launch", 11 | "program": "${workspaceFolder}\\bot.py", 12 | "console": "integratedTerminal" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.languageServer": "Pylance", 3 | "python.analysis.typeCheckingMode": "basic", 4 | "python.testing.pytestArgs": [ 5 | "tests" 6 | ], 7 | "python.testing.unittestEnabled": false, 8 | "python.testing.pytestEnabled": true, 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | 3 | >此处仅为记录重大更新,修复 BUG/以及其它 请关注 [Github Commits](https://github.com/netsora/sorabot/commit/master) 4 | 5 | ## Coming soon... 6 | 7 | * Next:`v1.1.0` 8 | 9 | ## May 27,2023 10 | * 更新版本至:`v1.0.0` 11 | * 🎉 Nice to meet you! 12 | 13 | ## 更早记录 14 | > 本项目始于 2022年8月,太早的记不清了 ~~(大多都是cv)~~ 15 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # SoraBot 贡献者公约 2 | 3 | ## 我们的承诺 4 | 5 | 身为项目成员、贡献者、负责人,我们保证参与此社区的每个人都不受骚扰,不论其年龄、体型、身体条件、民族、性征、性别认同与表现、经验水平、教育程度、社会地位、国籍、相貌、种族、宗教信仰及性取向如何。 6 | 7 | 我们承诺致力于建设开放、友善、多元、包容、健康的社区环境。 8 | 9 | ## 我们的准则 10 | 11 | 有助于促进本社区积极环境的行为包括但不限于: 12 | 13 | - 与人为善、推己及人 14 | - 尊重不同的主张、观点和经历 15 | - 积极提出、耐心接受有益批评 16 | - 面对过失,承担责任、认真道歉、从中学习 17 | - 关注社区共同诉求,而非一己私利 18 | 19 | 不当行为包括但不限于: 20 | 21 | - 发布与性有关的言论或图像,以及任何形式的献殷勤或勾引 22 | - 挑衅行为、侮辱或贬损的言论、人身及政治攻击 23 | - 公开或私下骚扰 24 | - 未获明确授权擅自发布他人的资料,如地址、电子邮箱等 25 | - 其他有理由认定为违反职业操守的不当行为 26 | 27 | ## 落实之义务 28 | 29 | 社区负责人有责任诠释什么是“妥当行为”,并据此准则,妥善公正地认定与处置不当、威胁、冒犯及有害的行为。 30 | 31 | 社区负责人有权利和义务删除、编辑、拒绝违背本公约的评论(comment)、提交(commit)、代码、维基(wiki)编辑、问题(issue)等贡献。如有必要,需告知采取措施的理由。 32 | 33 | ## 适用范围 34 | 35 | 此行为标准适用于本社区全部场合,以及在其他场合代表本社区的个人。 36 | 37 | 代表本社区的情形包括但不限于:使用官方电子邮件与社交平台、作为指定代表参与在线或线下活动。 38 | 39 | ## 贯彻落实 40 | 41 | 如遇滥用、骚扰等不当行为,请通过 sora@netsora.info 向我们举报。我们将迅速审议并调查全部投诉。 42 | 43 | 社区全体负责人有义务保密举报者信息。 44 | 45 | ## 指导方针 46 | 47 | 社区负责人将依据下列方案判断并处置违纪行为: 48 | 49 | ### 一、督促 50 | 51 | **社区影响**:用语不当、举止不符合道德或不受社区欢迎。 52 | 53 | **处理意见**:由社区负责人予以非公开的书面警告,阐明违纪事由、解释举止如何不妥。或要求公开道歉。 54 | 55 | ### 二、警告 56 | 57 | **社区影响**:一起或多起事件中的违纪行为。 58 | 59 | **处理意见**:警告继续违纪的后果、违纪者在特定时间内禁止与当事人往来、不得擅自与社区执法者往来,禁令涵盖社区内外、社交网络在内的一切联络。如有违反,可致封禁乃至开除。 60 | 61 | ### 三、封禁 62 | 63 | **社区影响**:严重违纪行为,包括屡教不改。 64 | 65 | **处理意见**:违纪者在特定时间内禁止与社区的任何往来或公开联络,禁止任何与当事人公开或私下往来,不得擅自与社区管理者往来。如有违反,可导致开除。 66 | 67 | ### 四、开除 68 | 69 | **社区影响**:典型违纪行为,例如屡教不改、骚扰某个人、敌对或贬低某个群体。 70 | 71 | **处理意见**:无限期禁止违纪者与项目社区的一切公开往来。 72 | 73 | ## 来源 74 | 75 | 本行为标准改编自 [参与者公约2.0][homepage] 版,可在此查阅:[https://www.contributor-covenant.org/zh-cn/version/2/0/code_of_conduct.html][v2.0] 76 | 77 | 指导方针借鉴自 [Mozilla 纪检分级][mozilla coc]。 78 | 79 | 此行为标准常见问题请洽:[https://www.contributor-covenant.org/faq][faq]。 80 | 另有诸译本:[https://www.contributor-covenant.org/translations][translations]。 81 | 82 | [homepage]: https://www.contributor-covenant.org 83 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 84 | [mozilla coc]: https://github.com/mozilla/diversity 85 | [faq]: https://www.contributor-covenant.org/faq 86 | [translations]: https://www.contributor-covenant.org/translations 87 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # SoraBot 贡献指南 2 | 首先,感谢大家为 SoraBot 贡献代码 3 | 本张旨在引导你更规范地向 SoraBot 提交贡献,请务必认真阅读。 4 | 5 | ## 提交 Issue 6 | 7 | 在提交 Issue 前,我们建议你先查看 [FAQ](https://github.com/orgs/netsora/discussions) 与 [已有的 Issues](https://github.com/netsora/SoraBot/issues),以防重复提交。 8 | 9 | ### 报告问题、故障与漏洞 10 | 11 | SoraBot 仍然是一个不够稳定的开发中项目,如果你在使用过程中发现问题并确信是由 SoraBot 引起的,欢迎提交 Issue。 12 | 13 | ### 建议功能 14 | 15 | SoraBot 还未进入正式版,欢迎在 Issue 中提议要加入哪些新功能。 16 | 17 | 为了让开发者更好地理解你的意图,请认真描述你所需要的特性,可能的话可以提出你认为可行的解决方案。 18 | 19 | ## Pull Request 20 | 21 | SoraBot 使用 poetry 管理项目依赖,由于 pre-commit 也经其管理,所以在此一并说明。 22 | 23 | 下面的命令能在已安装 poetry 和 yarn 的情况下帮你快速配置开发环境。 24 | 25 | ```cmd 26 | # 安装 python 依赖 27 | poetry install 28 | # 安装 pre-commit git hook 29 | pre-commit install 30 | ``` 31 | 32 | ### 使用 GitHub Codespaces(Dev Container) 33 | 34 | [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=master&repo=645755460) 35 | 36 | ### 使用 GitPod 37 | 38 | [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#/https://github.com/netsora/SoraBot) 39 | 40 | ### Commit 规范 41 | 42 | 请确保你的每一个 commit 都能清晰地描述其意图,一个 commit 尽量只有一个意图。 43 | 44 | SoraBot 的 commit message 格式遵循 gitmoji 规范,在创建 commit 时请牢记这一点。 45 | 46 | 47 | ### 工作流概述 48 | 49 | `master` 分支为 SoraBot 的开发分支,在任何情况下都请不要直接修改 `master` 分支,而是创建一个目标分支为 `sorabot:master` 的 Pull Request 来提交修改。Pull Request 标题请尽量更改成中文,以便阅读。 50 | 51 | 如果你不是 netsora 团队的成员,可在 fork 本仓库后,向本仓库的 master 分支发起 Pull Request,注意遵循先前提到的 commit message 规范创建 commit。我们将在 code review 通过后通过 squash merge 方式将您的贡献合并到主分支。 52 | 53 | ### 撰写文档 54 | 55 | SoraBot 的文档使用 [vuepress-reco](http://v2.vuepress-reco.recoluan.com/),它有一些 [Markdown 特性](http://v2.vuepress-reco.recoluan.com/docs/theme/custom-container.html) 可能会帮助到你。 56 | 57 | 如果你需要在本地预览修改后的文档,可以使用 yarn 安装文档依赖后启动 dev server,如下所示: 58 | 59 | ```cmd 60 | yarn install 61 | yarn start 62 | ``` 63 | 64 | SoraBot 文档并没有具体的行文风格规范,但我们建议你尽量写得简单易懂。 65 | 66 | 以下是比较重要的编写与排版规范。目前 SoraBot 文档中仍有部分文档不完全遵守此规范,如果在阅读时发现欢迎提交 PR。 67 | 68 | 1. 中文与英文、数字、半角符号之间需要有空格。例:`SoraBot 是基于 Nonebot2 开发的多平台机器人。` 69 | 2. 若非英文整句,使用全角标点符号。例:`现在你可以看到机器人回复你:“Hello, World !”。` 70 | 3. 直引号 `「」` 和弯引号 `“”` 都可接受,但同一份文件里应使用同种引号。 71 | 4. **不要使用斜体**,你不需要一种与粗体不同的强调。除此之外,你也可以考虑使用 vuepress 提供的[扩展](https://vuepress-theme-reco.recoluan.com/docs/theme/custom-container.htm)。 72 | 5. 文档中应以“我们”指代林汐开发者(即管理员与协助者),以“您”指代机器人的使用者。 73 | 74 | 以上由[社区创始人 richardchien 的中文排版规范](https://stdrc.cc/style-guides/chinese)补充修改得到。 75 | 76 | 如果你需要编辑器检查 Markdown 规范,可以在 VSCode 中安装 markdownlint 扩展。 77 | 78 | ### 文件夹规范 79 | 80 | 实际上,若无开发需求,您暂时不用了解每一个文件的作用 81 | 82 | ```cmd 83 | 📦 SoraBot 84 | ├── 📂 go-cqhttp 85 | │ └── ...... 86 | ├── 📂 SoraBot 87 | │ └── 📂 sora 88 | │ └── 📂 configs // 配置 89 | │ └── 📂 database // 数据库 90 | │ └── 📂 plugins // 插件 91 | │ └── 📂 utils // 工具 92 | │ └── 📜 ... 93 | │ └── 📂 website 94 | │ └── 📂 .vitepress 95 | │ └── 📜 config.ts // 配置文件 96 | │ └── 📂 develop // 开发文档 97 | │ └── 📂 module // 使用文档 98 | │ └── 📜 package.json 99 | │ └── 📜 README.md 100 | │ └── 📜 .env // 配置文件 101 | │ └── 📜 .env.prod // 配置文件 102 | │ └── 📜 bot.py // 启动文件 103 | │ └── 📜 matcher_patch.py 104 | │ └── 📜 poetry.lock // 依赖管理 105 | │ └── 📜 pyproject.toml 106 | | └── 📜 README.md 107 | | └── 📜 requirements.txt // 依赖列表 108 | ``` 109 | 110 | ### 参与开发 111 | 112 | SoraBot 的代码风格遵循 [PEP 8](https://www.python.org/dev/peps/pep-0008/) 与 [PEP 484](https://www.python.org/dev/peps/pep-0484/) 规范,请确保你的代码风格和项目已有的代码保持一致,变量命名清晰,有适当的注释与测试代码。 113 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | SoraBot 4 |
5 | 6 |
7 | 8 | # SoraBot 9 | _✨ 基于 Nonebot2,互通多平台,超可爱的林汐酱 ✨_ 10 |
11 | 12 | 13 |

14 | 15 | license 16 | 17 | python 18 | 19 | black 20 | 21 | 22 | pyright 23 | 24 | 25 | ruff 26 | 27 |
28 | 29 | site 30 | 31 | 32 | pre-commit 33 | 34 | 35 | pyright 36 | 37 | 38 | ruff 39 | 40 |
41 | 42 | onebot 43 | 44 | 45 | telegram 46 | 47 | 48 | QQ频道 49 | 50 |
51 | 52 | QQ Chat Group 53 | 54 | 55 | QQ Channel 56 | 57 | 60 |

61 | 62 |

63 | 文档 64 | · 65 | 服务列表 66 | · 67 | 安装文档 68 | · 69 | 文档打不开? 70 |

71 | 72 | ## 简介 73 | 74 | > [!Note] 75 | > 一切开发旨在学习,请勿用于非法用途 76 | 77 | > [!IMPORTANT] 78 | > 作者升入高中,暂不能经常维护 79 | 80 | 林汐(SoraBot)基于 Nonebot2 开发,互通多平台,以 Sqlite3 作为数据库的功能型机器人 81 | 82 | ## 特性 83 | 84 | - 使用 NoneBot2 进行项目底层构建. 85 | - 使用 go-cqhttp 作为默认协议端. 86 | - 互通 QQ、QQ频道、Telegram 等平台 87 | - 独立ID,更方便管理与互通数据 88 | - 全新的权限系统,不用重启便可自定义 Bot管理员 和 Bot协助者 89 | - Coming soon... 90 | 91 | ## 你可能会问 92 | 93 | **什么是独立ID,它有什么用?** 94 | 独立ID是林汐为每个用户分配的专属ID,通过它,我们便可知晓用户信息、绑定信息、权限等,以便我们更好向用户提供服务 95 | 96 | **全新的权限系统,新在哪里?** 97 | 林汐的权限系统,并没有使用 Nonebot2 所提供的 `SUPERUSER`,而是改为了 `Bot管理员` 和 `Bot协助者` 98 | 99 | ## 配置 100 | 101 | SoraBot 文档:~~[📖这里](bot.netsora.info)~~ 仓库内介绍:[📦这里](https://github.com/netsora/SoraBot/wiki) 102 | 103 | ## 更新日志 104 | 105 | 版本更新请参考[此处](./CHANGELOG.md). 106 | 107 | 小改动请参考以往的 [commit](https://github.com/netsora/SoraBot/commit/master). 108 | 109 | ## 贡献 110 | 如果你喜欢本项目,可以请我[喝杯快乐水](https://afdian.net/@netsora) 111 | 112 | 如果你有想法、有能力,欢迎: 113 | 114 | * [提交 Issue](https://github.com/netsora/SoraBot/issues) 115 | * [提交 Pull request](https://github.com/netsora/SoraBot/pulls) 116 | * [在交流群内反馈](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=kUsNnKC-8F_YnR6VvYGqDiZOmhSi-iw7&authKey=IlG5%2FP1LrCVfniACFmdKRRW1zXq6fto5a43vfAHBqC5dUNztxLRuJnrVou2Q8UgH&noverify=0&group_code=817451732) 117 | 118 | 请参考 [贡献指南](./CONTRIBUTING.md) 119 | 120 | ## 鸣谢 121 | 122 | 感谢以下 开发者 和 Github 项目对 SoraBot 作出的贡献:(排名不分先后) 123 | - [`nonebot/nonebot2`](https://github.com/nonebot/nonebot2):跨平台Python异步机器人框架 124 | - [`A-kirami/KiramiBot`](https://github.com/A-kirami/KiramiBot):简明轻快的聊天机器人应用。 125 | - [`Kyomotoi/ATRI`](https://github.com/Kyomotoi/ATRI):高性能文爱萝卜子 126 | - [`HibiKier/zhenxun_bot`](https://github.com/HibiKier/zhenxun_bot):非常可爱的绪山真寻bot 127 | - [`CMHopeSunshine/LittlePaimon`](https://github.com/CMHopeSunshine/LittlePaimon):原神Q群机器人 128 | - [`nonebot_plugin_saa`](https://github.com/felinae98/nonebot-plugin-send-anything-anywhere):多适配器消息发送支持 129 | - [`nonebot_plugin_alconna`](https://github.com/nonebot/plugin-alconna):强大的 Nonebot2 命令匹配拓展 130 | 131 | ## 许可证 132 | 133 | 本项目使用 AGPLv3. 134 | 135 | 意味着你可以运行本项目,并向你的用户提供服务。除非获得商业授权,否则无论以何种方式修改或者使用代码,都需要开源 136 | 137 | ## 活动 138 | 139 | ![Repo Beats](https://repobeats.axiom.co/api/embed/3f28eb2c8fe036be9117c61a8b1bf1b445c12310.svg "Repobeats analytics image") 140 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | from sora import SoraBot 2 | 3 | bot = SoraBot() 4 | 5 | if __name__ == "__main__": 6 | bot.run() 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "sorabot" 3 | version = "1.0.0" 4 | description = "基于 NoneBot2 开发,互通多平台,超可爱的林汐酱" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | license = {text = "AGPL-3.0"} 8 | authors = [{name = "Komorebi", email = "mute231010@gmail.com"}] 9 | classifiers = [ 10 | "Development Status :: 4 - Beta", 11 | "Framework :: Robot Framework", 12 | "Framework :: Robot Framework :: Library", 13 | "License :: OSI Approved :: GNU Affero General Public License v3", 14 | "Operating System :: OS Independent", 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3 :: Only", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Topic :: Software Development", 21 | "Typing :: Typed", 22 | ] 23 | dependencies = [ 24 | "nonebot2[fastapi]>=2.3.0", 25 | "tortoise>=0.1.1", 26 | "tortoise-orm>=0.20.0", 27 | "ujson>=5.8.0", 28 | "ruamel-yaml>=0.17.32", 29 | "apscheduler>=3.10.3", 30 | "httpx>=0.24.1", 31 | "aiofiles>=23.2.1", 32 | "retrying>=1.3.4", 33 | "rich>=13.5.2", 34 | "GitPython>=3.1.32", 35 | "nonebot-plugin-send-anything-anywhere>=0.6.1", 36 | "nonebot-plugin-alconna>=0.48.0", 37 | "nonebot-plugin-userinfo>=0.1.3", 38 | "pillow>=10.0.0", 39 | "websockets>=11.0.3", 40 | "pandas>=2.0.3", 41 | "toml>=0.10.2", 42 | "dnspython>=2.5.0", 43 | "psutil>=5.9.8", 44 | "py-cpuinfo>=9.0.0", 45 | ] 46 | 47 | [project.urls] 48 | homepage = "https://bot.netsora.info/" 49 | repository = "https://github.com/netsora/SoraBot" 50 | documentation = "https://bot.netsora.info/" 51 | 52 | [project.optional-dependencies] 53 | adapters = [ 54 | "nonebot-adapter-onebot>=2.4.3", 55 | "nonebot-adapter-qq>=1.3.5", 56 | "nonebot-adapter-telegram>=0.1.0b17", 57 | ] 58 | 59 | [tool.pdm.dev-dependencies] 60 | dev = [ 61 | "black>=23.7.0", 62 | "ruff>=0.0.284", 63 | "pre-commit>=3.3.3", 64 | "isort>=5.13.2", 65 | ] 66 | 67 | [tool.pdm.scripts] 68 | start = "pdm run bot.py" 69 | 70 | [tool.black] 71 | line-length = 88 72 | target-version = ["py310", "py311"] 73 | include = '\.pyi?$' 74 | extend-exclude = ''' 75 | ''' 76 | 77 | [tool.isort] 78 | profile = "black" 79 | line_length = 88 80 | length_sort = true 81 | skip_gitignore = true 82 | force_sort_within_sections = true 83 | extra_standard_library = ["typing_extensions"] 84 | 85 | 86 | [tool.ruff] 87 | select = ["E", "W", "F", "UP", "C", "T", "PYI", "PT", "Q"] 88 | ignore = ["E402", "C901"] 89 | line-length = 120 90 | target-version = "py310" 91 | 92 | [tool.ruff.flake8-pytest-style] 93 | fixture-parentheses = false 94 | mark-parentheses = false 95 | 96 | [tool.pyright] 97 | pythonVersion = "3.10" 98 | pythonPlatform = "All" 99 | executionEnvironments = [ 100 | { root = "./tests", extraPaths = ["./"] }, 101 | { root = "./" }, 102 | ] 103 | typeCheckingMode = "basic" 104 | reportShadowedImports = false 105 | 106 | [build-system] 107 | requires = ["pdm-backend"] 108 | build-backend = "pdm.backend" 109 | -------------------------------------------------------------------------------- /resources/font/ADLaMDisplay-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netsora/SoraBot/fe1d23394157a1570ccc7cf4cbfecee14c53a976/resources/font/ADLaMDisplay-Regular.ttf -------------------------------------------------------------------------------- /resources/font/SpicyRice-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netsora/SoraBot/fe1d23394157a1570ccc7cf4cbfecee14c53a976/resources/font/SpicyRice-Regular.ttf -------------------------------------------------------------------------------- /resources/font/baotu.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netsora/SoraBot/fe1d23394157a1570ccc7cf4cbfecee14c53a976/resources/font/baotu.ttf -------------------------------------------------------------------------------- /resources/image/status/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netsora/SoraBot/fe1d23394157a1570ccc7cf4cbfecee14c53a976/resources/image/status/background.png -------------------------------------------------------------------------------- /resources/image/status/marker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netsora/SoraBot/fe1d23394157a1570ccc7cf4cbfecee14c53a976/resources/image/status/marker.png -------------------------------------------------------------------------------- /resources/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netsora/SoraBot/fe1d23394157a1570ccc7cf4cbfecee14c53a976/resources/logo.jpg -------------------------------------------------------------------------------- /sora/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from pathlib import Path 3 | from typing import Any, ClassVar 4 | 5 | import nonebot 6 | from nonebot.plugin import Plugin 7 | from nonebot.drivers import Driver 8 | from nonebot.utils import path_to_module_name 9 | from nonebot.plugin.manager import PluginManager, _managers 10 | 11 | from .log import console 12 | from .hook import install_hook 13 | from .log import logger as logger 14 | from .log import Text, Panel, Columns 15 | from .version import __version__, __metadata__ 16 | from .config import bot_config, sora_config, plugin_config 17 | 18 | 19 | class SoraBot: 20 | driver: ClassVar[Driver] 21 | 22 | def __init__(self) -> None: 23 | self.show_logo() 24 | 25 | if bot_config.debug: 26 | self.print_environment() 27 | console.rule() 28 | 29 | self.init_nonebot(_mixin_config(bot_config.dict())) 30 | 31 | logger.opt(colors=True).success("🚀 SoraBot is initializing...") 32 | 33 | logger.opt(colors=True).debug( 34 | f"Loaded Config: {sora_config.dict()}" 35 | ) 36 | 37 | self.load_plugins() 38 | 39 | install_hook() 40 | 41 | logger.opt(colors=True).success("🚀 SoraBot is Running...") 42 | 43 | if bot_config.debug: 44 | console.rule("[blink][yellow]当前处于调试模式中, 请勿在生产环境打开[/][/]") 45 | 46 | def run(self, *args, **kwargs) -> None: 47 | """Sora,启动!""" 48 | self.driver.run(*args, **kwargs) 49 | 50 | def init_nonebot(self, config: dict[str, Any]) -> None: 51 | """初始化 NoneBot""" 52 | nonebot.init(**config) 53 | 54 | self.__class__.driver = nonebot.get_driver() 55 | self.load_adapters(config["adapters"]) 56 | 57 | def load_adapters(self, adapters: set[str]) -> None: 58 | """加载适配器""" 59 | adapters = {adapter.replace("~", "nonebot.adapters.") for adapter in adapters} 60 | for adapter in adapters: 61 | module = importlib.import_module(adapter) 62 | self.driver.register_adapter(getattr(module, "Adapter")) 63 | 64 | def load_plugins(self) -> None: 65 | """加载插件""" 66 | plugins = { 67 | path_to_module_name(pp) if (pp := Path(p)).exists() else p 68 | for p in plugin_config.plugins 69 | } 70 | manager = PluginManager(plugins, plugin_config.plugin_dirs) 71 | plugins = manager.available_plugins 72 | _managers.append(manager) 73 | 74 | if plugin_config.whitelist: 75 | plugins &= plugin_config.whitelist 76 | 77 | if plugin_config.blacklist: 78 | plugins -= plugin_config.blacklist 79 | 80 | loaded_plugins = set( 81 | filter(None, (manager.load_plugin(name) for name in plugins)) 82 | ) 83 | 84 | self.loading_state(loaded_plugins) 85 | 86 | def loading_state(self, plugins: set[Plugin]) -> None: 87 | """打印插件加载状态""" 88 | if loaded_plugins := nonebot.get_loaded_plugins(): 89 | logger.opt(colors=True).info( 90 | f"✅ [magenta]Total {len(loaded_plugins)} plugin are successfully loaded.[/]" 91 | ) 92 | 93 | if failed_plugins := plugins - loaded_plugins: 94 | logger.opt(colors=True).error( 95 | f"❌ [magenta]Total {len(failed_plugins)} plugin are failed loaded.[/]: {', '.join(plugin.name for plugin in failed_plugins)}" # noqa: E501 96 | ) 97 | 98 | def show_logo(self) -> None: 99 | """打印 LOGO""" 100 | console.print( 101 | Columns( 102 | [Text(LOGO.lstrip("\n"), style="bold blue")], 103 | align="center", 104 | expand=True, 105 | ) 106 | ) 107 | 108 | def print_environment(self) -> None: 109 | """打印环境信息""" 110 | import platform 111 | 112 | environment_info = { 113 | "OS": platform.system(), 114 | "Arch": platform.machine(), 115 | "Python": platform.python_version(), 116 | "SoraBot": __version__, 117 | "NoneBot": nonebot.__version__, 118 | } 119 | 120 | renderables = [ 121 | Panel( 122 | Text(justify="center") 123 | .append(k, style="bold") 124 | .append(f"\n{v}", style="yellow"), 125 | expand=True, 126 | width=console.size.width // 6, 127 | ) 128 | for k, v in environment_info.items() 129 | ] 130 | console.print( 131 | Columns( 132 | renderables, 133 | align="center", 134 | title="Environment Info", 135 | expand=True, 136 | equal=True, 137 | ) 138 | ) 139 | 140 | 141 | def get_driver() -> Driver: 142 | """ 143 | 获取全局 `Driver` 实例。 144 | """ 145 | if SoraBot.driver is None: 146 | raise ValueError("SoraBot has not been initialized.") 147 | return SoraBot.driver 148 | 149 | 150 | def _mixin_config(config: dict[str, Any]) -> dict[str, Any]: 151 | if config["debug"]: 152 | config |= { 153 | "fastapi_openapi_url": config.get("fastapi_openapi_url", "/openapi.json"), 154 | "fastapi_docs_url": config.get("fastapi_docs_url", "/docs"), 155 | "fastapi_redoc_url": config.get("fastapi_redoc_url", "/redoc"), 156 | } 157 | config["fastapi_extra"] = { 158 | "title": __metadata__.name, 159 | "version": __metadata__.version, 160 | "description": __metadata__.summary, 161 | } 162 | 163 | return config 164 | 165 | 166 | LOGO = r""" 167 | _____ ____ __ 168 | / ___/____ _________ _ / __ )____ / /_ 169 | \__ \/ __ \/ ___/ __ `/ / __ / __ \/ __/ 170 | ___/ / /_/ / / / /_/ / / /_/ / /_/ / /_ 171 | /____/\____/_/ \__,_/ /_____/\____/\__/ 172 | """ 173 | -------------------------------------------------------------------------------- /sora/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import SoraConfig 2 | from .utils import load_config 3 | from .path import BOT_DIR as BOT_DIR 4 | from .path import LOG_DIR as LOG_DIR 5 | from .path import RES_DIR as RES_DIR 6 | from .path import DATA_DIR as DATA_DIR 7 | from .path import FONT_DIR as FONT_DIR 8 | from .path import PAGE_DIR as PAGE_DIR 9 | from .path import AUDIO_DIR as AUDIO_DIR 10 | from .path import IMAGE_DIR as IMAGE_DIR 11 | from .path import VIDEO_DIR as VIDEO_DIR 12 | from .config import BaseConfig as BaseConfig 13 | 14 | sora_config = SoraConfig(**load_config()) 15 | """SoraBot 配置""" 16 | 17 | bot_config = sora_config.bot 18 | """本体主要配置""" 19 | 20 | plugin_config = sora_config.plugin 21 | """插件加载相关配置""" 22 | 23 | log_config = sora_config.log 24 | """日志相关配置""" 25 | 26 | database_config = sora_config.database 27 | """数据库相关配置""" 28 | -------------------------------------------------------------------------------- /sora/config/config.py: -------------------------------------------------------------------------------- 1 | """本模块定义了 Sora 运行所需的配置项""" 2 | 3 | from datetime import timedelta 4 | from ipaddress import IPv4Address 5 | from types import MappingProxyType 6 | from typing_extensions import Self 7 | from collections.abc import Mapping, KeysView 8 | from typing import TYPE_CHECKING, Any, Literal, ClassVar, NoReturn, TypeAlias 9 | 10 | from nonebot.config import Env, Config 11 | from pydantic import Field, BaseModel, IPvAnyAddress, model_validator 12 | 13 | from .utils import find_plugin 14 | 15 | LevelName: TypeAlias = Literal[ 16 | "TRACE", "DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR", "CRITICAL" 17 | ] 18 | 19 | 20 | class BaseConfig(BaseModel, Mapping): 21 | __raw_config__: ClassVar[MappingProxyType[str, Any]] = MappingProxyType({}) 22 | 23 | def __getitem__(self, key: str) -> Any: 24 | try: 25 | return getattr(self, key) 26 | except AttributeError as e: 27 | raise RuntimeError( 28 | f"{self.__class__.__name__} 不存在 {key} 配置, 请检查拼写是否正确" 29 | ) from e 30 | 31 | def __setitem__(self, *_) -> NoReturn: 32 | raise RuntimeError("无法在运行时修改配置") 33 | 34 | def __delitem__(self, _) -> NoReturn: 35 | raise RuntimeError("无法在运行时修改配置") 36 | 37 | def __setattr__(self, *_) -> NoReturn: 38 | raise RuntimeError("无法在运行时修改配置") 39 | 40 | def __delattr__(self, _) -> NoReturn: 41 | raise RuntimeError("无法在运行时修改配置") 42 | 43 | def __len__(self) -> int: 44 | return len(self.__dict__) 45 | 46 | def __repr__(self) -> str: 47 | return f"<{self.__class__.__name__} {self.__dict__}>" 48 | 49 | def keys(self) -> KeysView[str]: 50 | return self.__dict__.keys() 51 | 52 | @classmethod 53 | def load_config(cls, namespace: str | None = None) -> Self: 54 | """加载配置。 55 | 56 | ### 参数 57 | namespace: 配置的命名空间,如果为 None 则自动查找并使用插件名称 58 | """ 59 | if namespace is None and (plugin := find_plugin(cls)): 60 | namespace = plugin.name 61 | 62 | if not namespace: 63 | raise RuntimeError("无法确定配置所属插件,请指定 namespace") 64 | 65 | return cls(**cls.__raw_config__.get(namespace, {})) 66 | 67 | 68 | class LogConfig(BaseConfig): 69 | log_expire_timeout: int = 7 70 | """日志文件过期时间""" 71 | 72 | 73 | class DatabaseConfig(BaseConfig): 74 | """ 75 | Sqlite 数据库配置 76 | """ 77 | 78 | url: str = "sqlite://db.sqlite3" 79 | """Sqlite 连接 URL""" 80 | 81 | database: str = "sora" 82 | """Sqlite 数据库名称""" 83 | 84 | 85 | class PluginConfig(BaseConfig): 86 | """ 87 | 插件加载配置 88 | """ 89 | 90 | plugins: set[str] = set() 91 | """加载的插件""" 92 | 93 | plugin_dirs: set[str] = set() 94 | """插件目录列表""" 95 | 96 | whitelist: set[str] | None = None 97 | """插件白名单,只加载指定插件""" 98 | 99 | blacklist: set[str] | None = None 100 | """插件黑名单,不加载指定插件""" 101 | 102 | 103 | class BotConfig(BaseConfig): 104 | """ 105 | Bot 主要配置。 106 | """ 107 | 108 | driver: str = "~fastapi" 109 | """Sora 运行所使用的 `Driver`""" 110 | 111 | adapters: set[str] = {"~onebot.v11"} 112 | """Sora 所使用的 `Adapter`""" 113 | 114 | host: IPvAnyAddress = IPv4Address("127.0.0.1") # type: ignore 115 | """Sora 的 HTTP 和 WebSocket 服务端监听的 IP/主机名""" 116 | 117 | port: int = Field(default=8120, ge=1, le=65535) 118 | """Sora 的 HTTP 和 WebSocket 服务端监听的端口""" 119 | 120 | debug: bool = False 121 | """是否以调试模式运行 Sora""" 122 | 123 | log_level: LevelName | int = "INFO" 124 | """配置 Sora 日志输出等级,可以为 `int` 类型等级或等级名称,参考 [loguru 日志等级](https://loguru.readthedocs.io/en/stable/api/logger.html#levels)""" 125 | 126 | log_file: LevelName | tuple[LevelName] = "ERROR" 127 | """Sora 的日志保存等级,必须为等级名称""" 128 | 129 | api_root: dict[str, str] = {} 130 | """以机器人 ID 为键,上报地址为值的字典""" 131 | 132 | api_timeout: float = 30.0 133 | """API 请求超时时间,单位: 秒""" 134 | 135 | onebot_access_token: str = Field(default=None, alias="access_token") 136 | """API 请求以及上报所需密钥,在请求头中携带""" 137 | 138 | secret: str | None = None 139 | """HTTP POST 形式上报所需签名,在请求头中携带""" 140 | 141 | nickname: set[str] = {"林汐", "Sora"} 142 | """机器人昵称""" 143 | 144 | command_start: set[str] = {"/"} 145 | """命令的起始标记,用于判断一条消息是不是命令""" 146 | 147 | command_sep: set[str] = {"."} 148 | """命令的分隔标记,用于将文本形式的命令切分为元组(实际的命令名)""" 149 | 150 | session_expire_timeout: timedelta = timedelta(minutes=2) 151 | """等待用户回复的超时时间""" 152 | 153 | proxy_url: str | None = None 154 | """HTTP 代理地址""" 155 | 156 | http_timeout: float = 10.0 157 | """HTTP 请求超时时间,单位: 秒""" 158 | 159 | browser: Literal["chromium", "firefox", "webkit"] = "chromium" 160 | """浏览器类型""" 161 | 162 | time_zone: str = "Asia/Shanghai" 163 | """时区""" 164 | 165 | default_policy_allow: set[str] = {"*"} 166 | """默认权限策略允许的内容列表""" 167 | 168 | env_file: str | None = Field(default=None, alias="_env_file") 169 | """配置文件名默认从 `.env.{env_name}` 中读取配置""" 170 | 171 | @model_validator(mode="before") 172 | def mixin_config(cls, values: dict[str, Any]) -> dict[str, Any]: 173 | config = Config(**values, _env_file=(".env", f".env.{Env().environment}")) 174 | return config.model_dump(exclude_unset=True) 175 | 176 | class Config: 177 | extra = "allow" 178 | 179 | if TYPE_CHECKING: 180 | 181 | def __getattr__(self, name: str) -> Any: 182 | ... 183 | 184 | 185 | class SoraConfig(BaseConfig): 186 | """ 187 | Sora 主要配置。 188 | """ 189 | 190 | bot: BotConfig 191 | """本体主要配置""" 192 | 193 | plugin: PluginConfig 194 | """插件加载相关配置""" 195 | 196 | log: LogConfig 197 | """日志相关配置""" 198 | 199 | database: DatabaseConfig 200 | """数据库相关配置""" 201 | 202 | @model_validator(mode="before") 203 | def set_default_config(cls, values: dict[str, Any]) -> dict[str, Any]: 204 | BaseConfig.__raw_config__ = MappingProxyType(values) 205 | for name, config in cls.__annotations__.items(): 206 | values.setdefault(name, config()) 207 | return values 208 | 209 | @property 210 | def config(self) -> MappingProxyType[str, Any]: 211 | """原始配置""" 212 | return BaseConfig.__raw_config__ 213 | -------------------------------------------------------------------------------- /sora/config/path.py: -------------------------------------------------------------------------------- 1 | """本模块定义了 Sora 运行所需的文件目录""" 2 | 3 | from pathlib import Path 4 | 5 | BOT_DIR = Path.cwd() 6 | """Bot 根目录""" 7 | 8 | DATA_DIR = BOT_DIR / "data" 9 | """数据保存目录""" 10 | 11 | RES_DIR = BOT_DIR / "resources" 12 | """资源文件目录""" 13 | 14 | LOG_DIR = DATA_DIR / "logs" 15 | """日志保存目录""" 16 | 17 | IMAGE_DIR = RES_DIR / "image" 18 | """图片文件目录""" 19 | 20 | VIDEO_DIR = RES_DIR / "video" 21 | """视频文件目录""" 22 | 23 | AUDIO_DIR = RES_DIR / "audio" 24 | """音频文件目录""" 25 | 26 | FONT_DIR = RES_DIR / "font" 27 | """字体文件目录""" 28 | 29 | PAGE_DIR = RES_DIR / "page" 30 | """网页文件目录""" 31 | 32 | for name, var in locals().copy().items(): 33 | if name.endswith("_DIR") and isinstance(var, Path): 34 | var.mkdir(parents=True, exist_ok=True) 35 | -------------------------------------------------------------------------------- /sora/config/utils.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import itertools 3 | from typing import Any 4 | from pathlib import Path 5 | from collections.abc import Callable 6 | 7 | from nonebot.plugin import Plugin 8 | 9 | try: # pragma: py-gte-311 10 | import tomllib # pyright: ignore[reportMissingImports] 11 | except ModuleNotFoundError: # pragma: py-lt-311 12 | import tomli as tomllib 13 | 14 | 15 | def load_config() -> dict[str, Any]: 16 | """ 17 | 加载配置。 18 | """ 19 | 20 | def load_file(path: str | Path) -> dict[str, Any]: 21 | return tomllib.loads(Path(path).read_text(encoding="utf-8")) 22 | 23 | file_name = ("sora", "sora.config") 24 | file_type = ("toml", "yaml", "yml", "json") 25 | config_files = itertools.product(file_name, file_type) 26 | for name, type in config_files: 27 | file = Path(f"{name}.{type}") 28 | if file.is_file(): 29 | return load_file(file) 30 | pyproject = load_file("pyproject.toml") 31 | return pyproject.get("tool", {}).get("sora", {}) 32 | 33 | 34 | def find_plugin(cls: Callable[..., Any], /) -> Plugin | None: 35 | """查找类所在的插件对象 36 | 37 | ### 参数 38 | cls: 查找的类 39 | """ 40 | module_name = cls.__module__ 41 | module = importlib.import_module(module_name) 42 | parts = module_name.split(".") 43 | 44 | for i in range(len(parts), 0, -1): 45 | current_module = ".".join(parts[:i]) 46 | module = importlib.import_module(current_module) 47 | if plugin := getattr(module, "__plugin__", None): 48 | return plugin 49 | 50 | return None 51 | -------------------------------------------------------------------------------- /sora/database/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from tortoise import Tortoise 4 | 5 | from sora.log import logger 6 | from sora.config import database_config 7 | from sora.hook import on_startup, on_shutdown 8 | 9 | from .models import Ban as Ban 10 | from .models import Bind as Bind 11 | from .models import Sign as Sign 12 | from .models import User as User 13 | 14 | DATABASE: dict[str, Any] = {"models": ["sora.database.models"]} 15 | 16 | 17 | @on_startup(pre=True) 18 | async def connect_database() -> None: 19 | """ 20 | 建立数据库连接 21 | """ 22 | try: 23 | await Tortoise.init( 24 | db_url=database_config.url, modules=DATABASE, timezone="Asia/Shanghai" 25 | ) 26 | await Tortoise.generate_schemas() 27 | logger.opt(colors=True).success("🗃️ [magenta]Database connected successful.[/]") 28 | 29 | except Exception as e: 30 | raise Exception("Database connection failed.") from e 31 | 32 | 33 | def register_database(model: str): 34 | """ 35 | 注册数据库 36 | """ 37 | models: list[str] = DATABASE["models"] 38 | models.append(model) 39 | 40 | 41 | @on_shutdown 42 | async def disconnect_database() -> None: 43 | """ 44 | 断开数据库连接 45 | """ 46 | await Tortoise.close_connections() 47 | logger.opt(colors=True).success("🗃️ [magenta]Database disconnected successful.[/]") 48 | 49 | 50 | # @scheduler.scheduled_job("cron", hour=0, minute=0, misfire_grace_time=10) 51 | # async def daily_reset(): 52 | # """ 53 | # 重置数据库相关设置 54 | # """ 55 | # ... 56 | -------------------------------------------------------------------------------- /sora/database/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .ban import Ban as Ban 2 | from .bind import Bind as Bind 3 | from .sign import Sign as Sign 4 | from .user import User as User 5 | -------------------------------------------------------------------------------- /sora/database/models/ban.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from tortoise import fields 4 | from tortoise.models import Model 5 | 6 | from sora.log import logger 7 | 8 | 9 | class Ban(Model): 10 | id = fields.IntField(pk=True, generated=True, auto_increment=True) 11 | uid = fields.CharField(max_length=10, unique=True) 12 | """用户ID""" 13 | ban_time = fields.BigIntField() 14 | """ban开始的时间""" 15 | duration = fields.BigIntField() 16 | """ban时长""" 17 | 18 | class Meta: 19 | table = "Ban" 20 | table_description = "封禁人员数据表" 21 | 22 | @classmethod 23 | async def check_ban_time(cls, uid: str) -> int | str: 24 | """ 25 | 说明: 26 | 检测用户被ban时长 27 | 参数: 28 | * uid: 用户id 29 | """ 30 | if user := await cls.filter(uid=uid).first(): 31 | if ( 32 | time.time() - (user.ban_time + user.duration) > 0 33 | and user.duration != -1 34 | ): 35 | return "" 36 | if user.duration == -1: 37 | return "∞" 38 | return int(time.time() - user.ban_time - user.duration) 39 | return "" 40 | 41 | @classmethod 42 | async def is_banned(cls, uid: str) -> bool: 43 | """ 44 | 说明: 45 | 判断用户是否被ban 46 | 参数: 47 | * uid: 用户id 48 | """ 49 | if await cls.check_ban_time(uid): 50 | return True 51 | else: 52 | await cls.unban(uid) 53 | return False 54 | 55 | @classmethod 56 | async def ban(cls, uid: str, duration: int) -> None: 57 | """ 58 | 说明: 59 | ban掉目标用户 60 | 参数: 61 | * uid: 目标用户id 62 | * duration: ban时长(秒),-1 表示永久封禁 63 | """ 64 | logger.debug("封禁", f"封禁用户,ID: {uid},时长: {duration}") 65 | if await cls.filter(uid=uid).first(): 66 | await cls.unban(uid) 67 | await cls.create( 68 | uid=uid, 69 | ban_time=time.time(), 70 | duration=duration, 71 | ) 72 | 73 | @classmethod 74 | async def unban(cls, uid: str) -> bool: 75 | """ 76 | 说明: 77 | unban用户 78 | 参数: 79 | * uid: 用户id 80 | """ 81 | if user := await cls.filter(uid=uid).first(): 82 | logger.debug("封禁", f"解除封禁:{uid}") 83 | await user.delete() 84 | return True 85 | return False 86 | -------------------------------------------------------------------------------- /sora/database/models/bind.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from tortoise import fields 4 | from tortoise.models import Model 5 | 6 | from .user import User 7 | 8 | 9 | class Bind(Model): 10 | id = fields.IntField(pk=True, generated=True, auto_increment=True) 11 | uid = fields.CharField(max_length=10) 12 | """用户ID""" 13 | origin_uid = fields.CharField(max_length=10, null=True) 14 | """用户原始ID""" 15 | platform = fields.CharField(max_length=50) 16 | """绑定平台""" 17 | pid = fields.CharField(max_length=50) 18 | """平台ID""" 19 | 20 | info: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( 21 | "models.User", related_name="bind", to_field="uid" 22 | ) 23 | 24 | class Meta: 25 | table = "Bind" 26 | 27 | @classmethod 28 | async def check_pid_exists(cls, platform: str, pid: str) -> bool: 29 | """ 30 | 说明: 31 | 判断 platform 账号 是否存在于 Bind 表中 32 | 33 | 参数: 34 | * platform: 平台 35 | * pid: 平台ID 36 | 37 | 返回: 38 | 如果存在则返回 `true`, 39 | 反之则返回 `false` 40 | """ 41 | exists = await cls.filter(platform=platform, pid=pid).exists() 42 | return exists 43 | 44 | @classmethod 45 | async def bind(cls, origin_uid: str, bind_uid: str) -> None: 46 | """ 47 | 说明: 48 | 绑定账号 49 | 参数: 50 | * origin_uid: 原 uid 51 | * bind_uid: 预绑定后的 uid 52 | """ 53 | bind = await Bind.get(uid=origin_uid) 54 | user = await User.get(uid=bind_uid) 55 | bind.origin_uid = origin_uid 56 | bind.uid = bind_uid 57 | bind.info = user 58 | await bind.save() 59 | 60 | @classmethod 61 | async def cancel(cls, origin_uid: str, rebind_uid: str, platform: str) -> None: 62 | """ 63 | 说明: 64 | 取消绑定 65 | 参数: 66 | * origin_uid: 原uid 67 | * rebind_uid: 预取消绑定的 uid 68 | """ 69 | bind = await Bind.get(uid=rebind_uid, platform=platform) 70 | user = await User.get(uid=origin_uid) 71 | bind.origin_uid = None 72 | bind.uid = origin_uid 73 | bind.info = user 74 | await bind.save() 75 | 76 | @classmethod 77 | async def get_user_bind(cls, pid: str) -> list[dict[str, Any]]: 78 | """ 79 | 说明: 80 | 获取用户绑定信息 81 | 参数: 82 | * pid: 平台ID 83 | """ 84 | user_data = await cls.filter(pid=pid).values("uid", "platform", "pid") 85 | uid = user_data[0]["uid"] 86 | data = await cls.filter(uid=uid).values("uid", "platform", "pid") 87 | return data 88 | -------------------------------------------------------------------------------- /sora/database/models/item.py: -------------------------------------------------------------------------------- 1 | from tortoise import fields 2 | from tortoise.models import Model 3 | 4 | 5 | class Item(Model): 6 | id = fields.IntField(pk=True, generated=True, auto_increment=True) 7 | """物品 ID""" 8 | name = fields.CharField(max_length=20) 9 | """物品名称""" 10 | description = fields.TextField(null=True, default="神秘") 11 | """物品介绍""" 12 | amount = fields.BigIntField() 13 | """物品数量""" 14 | 15 | class Meta: 16 | table = "Item" 17 | -------------------------------------------------------------------------------- /sora/database/models/sign.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from tortoise import fields 4 | from tortoise.models import Model 5 | 6 | 7 | class Sign(Model): 8 | id = fields.IntField(pk=True, generated=True, auto_increment=True) 9 | uid = fields.CharField(max_length=10) 10 | """用户 ID""" 11 | total_days = fields.IntField(default=0) 12 | """累计签到天数""" 13 | continuous_days = fields.IntField(default=0) 14 | """连续签到天数""" 15 | last_sign = fields.DatetimeField(null=True, alias="last_day") 16 | """上次签到日期""" 17 | 18 | class Meta: 19 | table = "Sign" 20 | 21 | @classmethod 22 | async def get_today_rank(cls, limit: int = 10) -> list: 23 | """ 24 | 说明: 25 | 获取当日签到排名前十的用户 26 | 参数: 27 | * limit: 排名限制(默认排名 top10) 28 | """ 29 | today = datetime.today() 30 | return await cls.filter(last_sign=today).order_by("-total_days").limit(limit) 31 | 32 | @classmethod 33 | async def get_total_rank(cls, limit: int = 10) -> list: 34 | """ 35 | 说明: 36 | 获取签到总排名 37 | 参数: 38 | * user_id 39 | * limit: 排名限制(默认排名 top10) 40 | """ 41 | return ( 42 | await cls.filter(last_sign__isnull=False) 43 | .order_by("-total_days") 44 | .limit(limit) 45 | ) 46 | 47 | @classmethod 48 | async def get_sign_info(cls, uid: str): 49 | """ 50 | 说明: 51 | 获取用户签到信息 52 | 参数: 53 | * uid 54 | """ 55 | return await cls.get_or_none(uid=uid) 56 | -------------------------------------------------------------------------------- /sora/database/models/user.py: -------------------------------------------------------------------------------- 1 | import random 2 | from datetime import date 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from tortoise import fields 6 | from tortoise.models import Model 7 | from nonebot.internal.adapter import Event 8 | from nonebot.internal.permission import Permission as Permission 9 | 10 | if TYPE_CHECKING: 11 | from .bind import Bind 12 | 13 | 14 | class User(Model): 15 | id = fields.IntField(pk=True, generated=True, auto_increment=True) 16 | uid = fields.CharField(max_length=10, unique=True) 17 | """指用户注册后 Sora 为其分配的ID""" 18 | user_name = fields.CharField(max_length=15, unique=True) 19 | """用户昵称""" 20 | level = fields.IntField(default=0) 21 | """等级""" 22 | exp = fields.IntField(default=0) 23 | """经验值""" 24 | coin = fields.IntField(default=0) 25 | """硬币数""" 26 | favor = fields.IntField(default=0) 27 | """好感度""" 28 | birthday = fields.DateField(null=True) 29 | """生日""" 30 | register_time = fields.DatetimeField(auto_now_add=True) 31 | """注册日期""" 32 | permission = fields.CharField(max_length=10) 33 | 34 | bind: fields.ReverseRelation["Bind"] 35 | 36 | class Meta: 37 | table = "User" 38 | 39 | @classmethod 40 | async def create_user( 41 | cls, 42 | uid: str, 43 | user_name: str, 44 | level: int = 1, 45 | exp: int = 0, 46 | coin: int = 50, 47 | favor: int = 0, 48 | birthday: date | None = None, 49 | permission: str = "NORMAL", 50 | ): 51 | """ 52 | 说明: 53 | 创建用户账号 54 | 参数: 55 | * uid: 用户ID 56 | * user_name: 用户名 57 | * level: 等级 58 | * exp: 经验值 59 | * coin: 硬币 60 | * favor: 好感度 61 | * birthday: 出生日期,默认为 None 62 | * permission: 权限 63 | """ 64 | return await cls.create( 65 | uid=uid, 66 | user_name=user_name, 67 | level=level, 68 | exp=exp, 69 | coin=coin, 70 | favor=favor, 71 | birthday=birthday, 72 | permission=permission, 73 | ) 74 | 75 | @classmethod 76 | async def delete_user(cls, uid: str) -> None: 77 | """ 78 | 说明: 79 | 删除用户账号及数据 80 | 参数: 81 | * uid: 用户ID 82 | """ 83 | user = await cls.get(uid=uid) 84 | await user.delete() 85 | 86 | @classmethod 87 | async def reward( 88 | cls, uid: str, *, reward: dict[str, Any] 89 | ) -> dict[str, int | float]: 90 | """ 91 | 说明: 92 | 奖励用户 93 | 参数: 94 | * reward: 奖励 95 | 返回: 96 | 奖励的数量 97 | """ 98 | user = await cls.get(uid=uid) 99 | reward_amount = {} 100 | for key, value in reward.items(): 101 | match key: 102 | case "coin": 103 | if isinstance(value, list): 104 | amount = random.randint(value[0], value[1]) 105 | elif isinstance(value, int): 106 | amount = value 107 | else: 108 | raise TypeError(f"Invalid reward type: {type(value)}") 109 | user.coin += amount 110 | reward_amount[key] = amount 111 | case "exp": 112 | if isinstance(value, list): 113 | amount = random.randint(value[0], value[1]) 114 | elif isinstance(value, int): 115 | amount = value 116 | else: 117 | raise TypeError(f"Invalid reward type: {type(value)}") 118 | user.exp += amount 119 | reward_amount[key] = amount 120 | case "favor": 121 | if isinstance(value, list): 122 | amount = random.randint(value[0], value[1]) 123 | elif isinstance(value, float | int): 124 | amount = float(value) 125 | else: 126 | raise TypeError(f"Invalid reward type: {type(value)}") 127 | user.favor += amount 128 | reward_amount[key] = amount 129 | case _: 130 | raise ValueError(f"Invalid reward key: {key}") 131 | await user.save() 132 | return reward_amount 133 | 134 | @classmethod 135 | async def check_username(cls, user_name: str) -> bool: 136 | """ 137 | 说明: 138 | 判断数据表中是否有相同的 user_name 139 | 参数: 140 | *user_name: 用户名 141 | """ 142 | return await cls.exists(user_name=user_name) 143 | 144 | @classmethod 145 | async def check_exists(cls, platform: str, pid: str): 146 | """ 147 | 判断用户是否存在 148 | """ 149 | return await cls.filter(bind__platform=platform, bind__pid=pid).exists() 150 | 151 | @classmethod 152 | async def get_user_by_uid(cls, uid: str): 153 | return await User.get(uid=uid) 154 | 155 | @classmethod 156 | async def get_user_by_pid(cls, pid: str): 157 | return await User.get(bind__pid=pid) 158 | 159 | @classmethod 160 | async def get_user_by_event(cls, event: Event): 161 | return await User.get(bind__pid=event.get_user_id()) 162 | -------------------------------------------------------------------------------- /sora/hook.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from collections import defaultdict 3 | from collections.abc import Callable 4 | from typing import Any, TypeVar, ParamSpec, cast 5 | 6 | from nonebot import get_driver 7 | from nonebot.typing import T_BotConnectionHook, T_BotDisconnectionHook 8 | 9 | R = TypeVar("R") 10 | P = ParamSpec("P") 11 | 12 | AnyCallable = Callable[..., Any] 13 | 14 | _backlog_hooks = defaultdict(list) 15 | 16 | _hook_installed = False 17 | 18 | 19 | def backlog_hook(hook: Callable[P, R]) -> Callable[P, R]: 20 | """在初始化之前暂存钩子""" 21 | 22 | @wraps(hook) 23 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 24 | if _hook_installed: 25 | return hook(*args, **kwargs) 26 | try: 27 | _backlog_hooks[hook].append(args[0]) 28 | return cast(R, args[0]) 29 | except IndexError: 30 | return hook(*args, **kwargs) 31 | 32 | return wrapper 33 | 34 | 35 | def install_hook() -> None: 36 | """将暂存的钩子安装""" 37 | for hook, funcs in _backlog_hooks.items(): 38 | while funcs: 39 | func = funcs.pop(0) 40 | hook(func) 41 | global _hook_installed # noqa: PLW0603 42 | _hook_installed = True 43 | 44 | 45 | @backlog_hook 46 | def on_startup(func: AnyCallable | None = None, pre: bool = False) -> AnyCallable: 47 | """在 `SoraBot` 启动时有序执行""" 48 | if func is None: 49 | return lambda f: on_startup(f, pre=pre) 50 | if pre: 51 | _backlog_hooks[on_startup.__wrapped__].insert(0, func) 52 | return func 53 | return get_driver().on_startup(func) 54 | 55 | 56 | @backlog_hook 57 | def on_shutdown(func: AnyCallable) -> AnyCallable: 58 | """在 `SoraBot` 停止时有序执行""" 59 | return get_driver().on_shutdown(func) 60 | 61 | 62 | @backlog_hook 63 | def on_bot_connect(func: T_BotConnectionHook) -> T_BotConnectionHook: 64 | """ 65 | 在 bot 成功连接到 `SoraBot` 时执行。 66 | """ 67 | return get_driver().on_bot_connect(func) 68 | 69 | 70 | @backlog_hook 71 | def on_bot_disconnect(func: T_BotDisconnectionHook) -> T_BotDisconnectionHook: 72 | """ 73 | 在 bot 与 `SoraBot` 连接断开时执行。 74 | """ 75 | return get_driver().on_bot_disconnect(func) 76 | -------------------------------------------------------------------------------- /sora/log.py: -------------------------------------------------------------------------------- 1 | import re 2 | import logging 3 | import builtins 4 | from functools import wraps 5 | from collections.abc import Callable 6 | from typing import TYPE_CHECKING, Any, Literal, ClassVar, TypeAlias, get_args 7 | 8 | import rich 9 | import loguru 10 | import nonebot 11 | from rich.theme import Theme 12 | from rich.markup import escape 13 | from rich.console import Console 14 | from rich.text import Text as Text 15 | from rich.traceback import install 16 | from loguru._handler import Message 17 | from rich.logging import RichHandler 18 | from rich.panel import Panel as Panel 19 | from rich.table import Table as Table 20 | from loguru._file_sink import FileSink 21 | from loguru._logger import Core, Logger 22 | from rich.columns import Columns as Columns 23 | from rich.markdown import Markdown as Markdown 24 | from rich.progress import Progress as Progress 25 | from nonebot.log import LoguruHandler as LoguruHandler 26 | 27 | from .config import LOG_DIR, bot_config, log_config 28 | 29 | if TYPE_CHECKING: 30 | from loguru import Record 31 | from loguru import Logger as LoggerType 32 | 33 | logger: "LoggerType" = loguru.logger 34 | 35 | LevelName: TypeAlias = Literal[ 36 | "TRACE", "DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR", "CRITICAL" 37 | ] 38 | 39 | suppress = () if bot_config.debug else (nonebot,) 40 | 41 | install(width=None, suppress=suppress, show_locals=bot_config.debug) 42 | 43 | builtins.print = rich.print 44 | 45 | for lv in Core().levels.values(): 46 | logging.addLevelName(lv.no, lv.name) 47 | 48 | 49 | color_dict = { 50 | "k": "black", 51 | "e": "blue", 52 | "c": "cyan", 53 | "g": "green", 54 | "m": "magenta", 55 | "r": "red", 56 | "w": "white", 57 | "y": "yellow", 58 | } 59 | 60 | style_dict = { 61 | "b": "bold", 62 | "d": "dim", 63 | "n": "normal", 64 | "i": "italic", 65 | "u": "underline", 66 | "s": "strike", 67 | "v": "reverse", 68 | "l": "blink", 69 | "h": "hide", 70 | } 71 | 72 | 73 | def tag_convert(match: re.Match[str]) -> str: 74 | """loguru 标签转写 rich 标签""" 75 | 76 | def get_color(color: str) -> str: 77 | if color.isnumeric(): 78 | return f"color({color})" 79 | return f"rgb({color})" if "," in color else color 80 | 81 | markup: str = match[0] 82 | tag: str = match[1] 83 | is_forecolor = tag.islower() 84 | tag = tag.lower() 85 | is_brightcolor = tag.startswith("light") 86 | tag = tag.removeprefix("light-") 87 | 88 | if len(tag) == 2: # noqa: PLR2004 89 | tag = tag[:-1] 90 | 91 | match tag.split(" "): 92 | case [abbr] if abbr in color_dict: 93 | color = color_dict.get(abbr, "") 94 | if is_brightcolor: 95 | color = f"bright_{color}" 96 | _type = color if is_forecolor else f"on {color}" 97 | case [abbr] if abbr in style_dict: 98 | _type = style_dict.get(abbr, "") 99 | case ["fg", color]: 100 | _type = get_color(color) 101 | case ["bg", color]: 102 | _type = f"on {get_color(color)}" 103 | case _: 104 | _type = tag 105 | 106 | return markup.replace("<", "[").replace(">", "]").replace(match[1], _type) 107 | 108 | 109 | def handle_log(func) -> Callable[..., None]: 110 | """将 loguru 的样式标记转换为 rich 的样式标记""" 111 | 112 | @wraps(func) 113 | def wrapper( 114 | self, 115 | level, 116 | from_decorator, 117 | options, 118 | message, 119 | args, 120 | kwargs, 121 | ) -> None: 122 | (exception, depth, record, lazy, colors, *_, extra) = options 123 | if colors: 124 | extra["colorize"] = True 125 | message = re.compile(r"(?\s]*)>").sub( 126 | tag_convert, message 127 | ) 128 | else: 129 | message = escape(message) 130 | options = (exception, depth + 1, record, lazy, False, *_, extra) 131 | return func( 132 | self, 133 | level, 134 | from_decorator, 135 | options, 136 | message, 137 | args, 138 | kwargs, 139 | ) 140 | 141 | return wrapper 142 | 143 | 144 | Logger._log = handle_log(Logger._log) 145 | 146 | 147 | def handle_write(func) -> Callable[..., None]: 148 | """清洗日志中的 rich 样式标记""" 149 | 150 | @wraps(func) 151 | def wrapper(self, message) -> None: 152 | record = message.record 153 | extra = record.get("extra", {}) 154 | if extra.get("colorize"): 155 | message = Message(re.sub(r"(\\*)(\[[a-z#/@][^[]*?])", "", message)) 156 | message.record = record 157 | return func(self, message) 158 | 159 | return wrapper 160 | 161 | 162 | FileSink.write = handle_write(FileSink.write) 163 | 164 | 165 | custom_theme = Theme( 166 | { 167 | "log.time": "cyan", 168 | "logging.level.debug": "blue", 169 | "logging.level.info": "", 170 | "logging.level.warning": "yellow", 171 | "logging.level.success": "bright_green", 172 | "logging.level.trace": "bright_black", 173 | }, 174 | ) 175 | 176 | console = Console(theme=custom_theme) 177 | 178 | handler = RichHandler( 179 | console=console, 180 | show_path=False, 181 | omit_repeated_times=False, 182 | markup=True, 183 | rich_tracebacks=True, 184 | tracebacks_show_locals=bot_config.debug, 185 | tracebacks_suppress=suppress, 186 | log_time_format="%m-%d %H:%M:%S", 187 | ) 188 | 189 | 190 | class LogFilter: 191 | level: ClassVar[LevelName | int] = ( 192 | "DEBUG" if bot_config.debug else bot_config.log_level 193 | ) 194 | 195 | def __call__(self, record: "Record") -> bool: 196 | level = record["extra"].get("filter_level") or self.level 197 | levelno = level if isinstance(level, int) else logger.level(level).no 198 | return record["level"].no >= levelno 199 | 200 | 201 | LOG_CONFIG = { 202 | "rotation": "00:00", 203 | "enqueue": True, 204 | "encoding": "utf-8", 205 | "retention": f"{log_config.log_expire_timeout} days", 206 | } 207 | 208 | 209 | def file_handler(levels: LevelName | tuple[LevelName, ...]) -> list[dict[str, Any]]: 210 | if not isinstance(levels, tuple): 211 | level_names = get_args(LevelName) 212 | minimum = level_names.index(levels) 213 | levels = level_names[minimum:] 214 | return [ 215 | { 216 | "sink": LOG_DIR / level.lower() / "{time:YYYY-MM-DD}.log", 217 | "level": level, 218 | **LOG_CONFIG, 219 | } 220 | for level in levels 221 | ] 222 | 223 | 224 | logger.remove() 225 | logger.configure( 226 | handlers=[ 227 | { 228 | "sink": handler, 229 | "level": 0, 230 | "colorize": False, 231 | "diagnose": False, 232 | "backtrace": True, 233 | "filter": LogFilter(), 234 | "format": lambda _: "[light_slate_blue bold][link={file.path}:{line}]{name}[/][/] [dim]|[/] {message}", 235 | }, 236 | *file_handler(bot_config.log_file), 237 | ] 238 | ) 239 | 240 | 241 | def new_logger( 242 | name: str, *, filter_level: LevelName | int | None = None 243 | ) -> "LoggerType": 244 | """创建新的日志记录器。 245 | 246 | ### 参数 247 | name: 日志名称 248 | 249 | filter_level: 过滤等级,当日志等级大于过滤等级时才会显示 250 | """ 251 | return logger.patch(lambda record: record.update({"name": name})).bind( 252 | filter_level=filter_level 253 | ) 254 | -------------------------------------------------------------------------------- /sora/permission.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from nonebot.internal.adapter.event import Event 4 | from nonebot.internal.permission import Permission as Permission 5 | 6 | from sora.database import User 7 | 8 | 9 | async def get_admin_list() -> list[tuple[Any, ...]]: 10 | """ 11 | 获取权限为 `Bot管理员` 的所有用户ID 12 | """ 13 | return await User.filter(permission="ADMIN").values_list("uid", flat=True) 14 | 15 | 16 | async def get_helper_list() -> list[tuple[Any, ...]]: 17 | """ 18 | 获取所有拥有 `Bot协助者` 权限的用户ID 19 | """ 20 | return await User.filter(permission="HELPER").values_list("uid", flat=True) 21 | 22 | 23 | class BotAdminUser: 24 | """检查当前事件是否是消息事件且属于 Bot管理员""" 25 | 26 | __slots__ = () 27 | 28 | def __repr__(self) -> str: 29 | return "BotAdminUser()" 30 | 31 | async def __call__(self, event: Event) -> bool: 32 | try: 33 | uid = (await User.get_user_by_event(event)).uid 34 | except Exception: 35 | return False 36 | 37 | admin_list: list[tuple[Any, ...]] = await get_admin_list() 38 | if uid in admin_list: 39 | return True 40 | else: 41 | return False 42 | 43 | 44 | class BotHelperUser: 45 | """检查当前事件是否是消息事件且属于 Bot协助者""" 46 | 47 | __slots__ = () 48 | 49 | def __repr__(self) -> str: 50 | return "BotHelperUser()" 51 | 52 | async def __call__(self, event: Event) -> bool: 53 | try: 54 | uid = (await User.get_user_by_event(event)).uid 55 | except Exception: 56 | return False 57 | 58 | helper_list: list[tuple[Any, ...]] = await get_helper_list() 59 | if uid in helper_list: 60 | return True 61 | else: 62 | return False 63 | 64 | 65 | ADMIN: Permission = Permission(BotAdminUser()) 66 | """Bot 管理员""" 67 | HELPER: Permission = Permission(BotHelperUser()) 68 | """Bot 协助者""" 69 | -------------------------------------------------------------------------------- /sora/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot import require 2 | 3 | require("nonebot_plugin_alconna") 4 | from nonebot_plugin_alconna import add_global_extension 5 | from nonebot_plugin_alconna.builtins.extensions.telegram import TelegramSlashExtension 6 | 7 | add_global_extension(TelegramSlashExtension()) 8 | -------------------------------------------------------------------------------- /sora/plugins/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot import require 2 | from nonebot.rule import to_me 3 | 4 | require("nonebot_plugin_saa") 5 | require("nonebot_plugin_alconna") 6 | 7 | from nonebot_plugin_saa import Text 8 | from nonebot_plugin_alconna import ( 9 | Args, 10 | Query, 11 | Option, 12 | Alconna, 13 | Subcommand, 14 | CommandMeta, 15 | namespace, 16 | on_alconna, 17 | ) 18 | 19 | from sora.log import logger 20 | from sora.hook import on_startup 21 | from sora.utils.api import random_text 22 | from sora.utils.annotated import UserInfo 23 | from sora.permission import get_admin_list, get_helper_list 24 | 25 | from .utils import check_permission 26 | 27 | authorize: str = "" 28 | 29 | __usage__ = """ 30 | USER 31 | 查看 Bot管理员 列表:/admin -l 32 | 33 | BOT_ADMIN 34 | 初始化权限:/admin -i 35 | 增加管理员:/admin add -a 36 | 增加协助者:/admin add -h 37 | 取消管理员:/admin remove -a 38 | 取消协助者:/admin remove -h 39 | """ 40 | 41 | with namespace("Sora") as ns: 42 | ns.builtin_option_name["help"] = {"--h", "--help"} 43 | admin = on_alconna( 44 | Alconna( 45 | "admin", 46 | Option("-l|--list", help_text="查看 Bot管理员 列表"), 47 | Option("-i|--init", Args["token", str], help_text="初始化权限"), 48 | Subcommand( 49 | "add", 50 | Option("-a|--admin", Args["target?", int], help_text="增加 Bot管理员"), 51 | Option("-h|--helper", Args["target?", int], help_text="增加 Bot协助者"), 52 | ), 53 | Subcommand( 54 | "remove", 55 | Option("-a|--admin", Args["target?", int], help_text="取消 Bot管理员"), 56 | Option("-h|--helper", Args["target?", int], help_text="取消 Bot协助者"), 57 | ), 58 | meta=CommandMeta( 59 | description="Bot管理员相关指令", 60 | usage=__usage__, 61 | example="/admin -l", 62 | compact=True, 63 | ), 64 | ), 65 | priority=5, 66 | block=True, 67 | rule=to_me(), 68 | ) 69 | admin.shortcut( 70 | r"(添加|增加)管理员\s+(\d+)", 71 | command="admin add --admin", 72 | arguments=["{1}"], 73 | fuzzy=True, 74 | prefix=True, 75 | ) 76 | admin.shortcut( 77 | r"(添加|增加)协助者\s+(\d+)", 78 | command="admin add --helper", 79 | arguments=["{1}"], 80 | fuzzy=True, 81 | prefix=True, 82 | ) 83 | admin.shortcut( 84 | r"(移除|取消)管理员\s+(\d+)", 85 | command="admin remove --admin", 86 | arguments=["{1}"], 87 | fuzzy=True, 88 | prefix=True, 89 | ) 90 | admin.shortcut( 91 | r"(移除|取消)协助者\s+(\d+)", 92 | command="admin remove --helper", 93 | arguments=["{1}"], 94 | fuzzy=True, 95 | prefix=True, 96 | ) 97 | 98 | 99 | @admin.assign("list") 100 | async def list(): 101 | admin_list = await get_admin_list() 102 | helper_list = await get_helper_list() 103 | 104 | await Text(f"Bot管理员列表:\n{admin_list}\nBot协助者列表:\n{helper_list}").send( 105 | at_sender=True 106 | ) 107 | await admin.finish() 108 | 109 | 110 | @admin.assign("init") 111 | async def init(user: UserInfo, token: str): 112 | global authorize 113 | if token == authorize: 114 | await user.filter(uid=user.uid).update(permission="ADMIN") 115 | await Text("已成功授权 Bot管理员 权限").send(at_sender=True) 116 | else: 117 | await Text("token不对呢,返回控制台检查一下吧").send(at_sender=True) 118 | await admin.finish() 119 | 120 | 121 | @admin.assign("add") 122 | async def add( 123 | user: UserInfo, 124 | admin_target: Query[int] = Query("add.admin.target"), 125 | helper_target: Query[int] = Query("add.helper.target"), 126 | ): 127 | if not await check_permission(user.uid, "ADMIN"): 128 | await Text("权限不足,需 Bot管理员 权限").send(at_sender=True) 129 | await admin.finish() 130 | 131 | if admin_target.available: 132 | if await user.filter(bind__uid=str(admin_target.result)).exists(): 133 | await user.filter(uid=admin_target.result).update(permission="ADMIN") 134 | await Text(f"已成功增加 Bot管理员:{admin_target.result}").send(at_sender=True) 135 | 136 | elif admin_target.result == 0: 137 | ... 138 | 139 | else: 140 | await Text(f"用户 {admin_target.result} 不存在").send(at_sender=True) 141 | 142 | if helper_target.available: 143 | if await user.filter(bind__uid=str(admin_target.result)).exists(): 144 | await user.filter(uid=helper_target.result).update(permission="HELPER") 145 | await Text(f"已成功增加 Bot协助者:{helper_target.result}").send(at_sender=True) 146 | 147 | elif helper_target.result == 0: 148 | ... 149 | 150 | else: 151 | await Text(f"用户 {helper_target.result} 不存在").send(at_sender=True) 152 | 153 | await admin.finish() 154 | 155 | 156 | @admin.assign("remove") 157 | async def remove( 158 | user: UserInfo, 159 | admin_target: Query[int] = Query("remove.admin.target"), 160 | helper_target: Query[int] = Query("remove.helper.target"), 161 | ): 162 | if not await check_permission(user.uid, "ADMIN"): 163 | await Text("权限不足,需 Bot管理员 权限").send(at_sender=True) 164 | await admin.finish() 165 | 166 | if admin_target.available: 167 | if await user.filter(bind__uid=str(admin_target.result)).exists(): 168 | await user.filter(uid=admin_target.result).update(permission="NORMAL") 169 | await Text(f"已取消 {admin_target.result} 的 Bot管理员 身份").send(at_sender=True) 170 | 171 | elif admin_target.result == 0: 172 | ... 173 | 174 | else: 175 | await Text(f"用户 {admin_target.result} 不存在").send(at_sender=True) 176 | 177 | if helper_target.available: 178 | if await user.filter(bind__uid=str(admin_target.result)).exists(): 179 | await user.filter(uid=helper_target.result).update(permission="NORMAL") 180 | await Text(f"已取消 {helper_target.result} 的 Bot协助者 身份").send(at_sender=True) 181 | 182 | elif helper_target.result == 0: 183 | ... 184 | 185 | else: 186 | await Text(f"用户 {helper_target.result} 不存在").send(at_sender=True) 187 | 188 | await admin.finish() 189 | 190 | 191 | @on_startup 192 | async def atuhorize_admin(): 193 | if not await get_admin_list(): 194 | global authorize 195 | authorize = random_text(20) 196 | logger.info(f"已自动生成授权token:{authorize}") 197 | logger.info(f"在任意平台发送 /admin -i {authorize} 即可获取 Bot管理员 权限") 198 | -------------------------------------------------------------------------------- /sora/plugins/admin/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from sora.database import User 4 | 5 | 6 | async def check_permission( 7 | uid: str, permission: Literal["ADMIN", "HELPER", "NORMAL", "BANNED"] 8 | ) -> bool: 9 | user = await User.get_user_by_uid(uid) 10 | return user.permission == permission 11 | -------------------------------------------------------------------------------- /sora/plugins/ban/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot import require 2 | from nonebot.rule import to_me 3 | from nonebot.internal.adapter import Bot 4 | 5 | require("nonebot_plugin_saa") 6 | require("nonebot_plugin_alconna") 7 | from nonebot_plugin_saa import Text 8 | from arclet.alconna.args import Args 9 | from arclet.alconna.base import Option 10 | from arclet.alconna.core import Alconna 11 | from arclet.alconna.typing import CommandMeta 12 | from nonebot_plugin_alconna import Match, on_alconna 13 | from nonebot_plugin_alconna.uniseg.segment import At 14 | 15 | from sora.log import logger 16 | from sora.database import Ban, User 17 | from sora.permission import ADMIN, HELPER 18 | 19 | ban = on_alconna( 20 | Alconna( 21 | "ban", 22 | Option( 23 | "add", 24 | Args["target", At | int]["hours?", int]["minutes?", int], 25 | help_text="封禁用户", 26 | ), 27 | Option("remove", Args["target", At | int], help_text="解封用户"), 28 | Option("-l|--list", help_text="查询所有被封禁用户"), 29 | meta=CommandMeta( 30 | description="你被逮捕了!丢进小黑屋!", 31 | usage="@bot /ban [小时] [分钟]", 32 | example=""" 33 | @bot /ban add @user 34 | @bot /ban add 2023081136 2 35 | @bot /ban add @user 2 30 36 | @bot /ban remove @user 37 | @bot /ban -l 38 | """, 39 | compact=True, 40 | ), 41 | ), 42 | priority=5, 43 | block=True, 44 | rule=to_me(), 45 | permission=ADMIN | HELPER, 46 | ) 47 | 48 | 49 | @ban.assign("add") 50 | async def add( 51 | bot: Bot, 52 | target: At | int, 53 | hours: Match[int], 54 | minutes: Match[int], 55 | ): 56 | if isinstance(target, At): 57 | pid = target.target 58 | if pid == bot.self_id: 59 | await Text("不要禁我,会把我弄哭的哦").finish(at_sender=True) 60 | if not await User.check_exists(bot.adapter.get_name(), pid): 61 | await Text("您@的用户还未注册喔").finish(at_sender=True) 62 | uid = (await User.get_user_by_pid(pid)).uid 63 | else: 64 | uid = str(target) 65 | 66 | user_name = (await User.get_user_by_uid(uid)).user_name 67 | 68 | if hours.available: 69 | if minutes.available: 70 | minutes_result = minutes.result 71 | else: 72 | minutes_result = 0 73 | hours_result = hours.result 74 | logger.info(f"封禁目标:{uid}({user_name}),时长:{hours_result}小时{minutes_result}分钟") 75 | await Ban.ban(uid, duration=convert_to_seconds(hours_result, minutes_result)) 76 | await Text( 77 | f"已成功封禁用户:{uid}({user_name}),时长:{hours_result}小时{minutes_result}分钟" 78 | ).send(at_sender=True) 79 | else: 80 | logger.info(f"永久封禁目标:{uid}({user_name})") 81 | await Ban.ban(uid, duration=-1) 82 | await Text(f"已永久封禁用户:{uid}({user_name})").send(at_sender=True) 83 | 84 | await ban.finish() 85 | 86 | 87 | @ban.assign("remove") 88 | async def unban( 89 | bot: Bot, 90 | target: At | int, 91 | hours: Match[int], 92 | minutes: Match[int], 93 | ): 94 | if isinstance(target, At): 95 | pid = target.target 96 | if not await User.check_exists(bot.adapter.get_name(), pid): 97 | await Text("您@的用户还未注册喔").finish(at_sender=True) 98 | uid = (await User.get_user_by_pid(pid)).uid 99 | else: 100 | uid = str(target) 101 | 102 | user_name = (await User.get_user_by_uid(uid)).user_name 103 | 104 | if hours.available: 105 | if minutes.available: 106 | minutes_result = minutes.result 107 | else: 108 | minutes_result = 0 109 | hours_result = hours.result 110 | logger.info(f"封禁目标:{uid}({user_name}),时长:{hours_result}小时{minutes_result}分钟") 111 | await Ban.unban(uid) 112 | await Text( 113 | f"已成功封禁用户:{uid}({user_name}),时长:{hours_result}小时{minutes_result}分钟" 114 | ).send(at_sender=True) 115 | else: 116 | logger.info(f"永久封禁目标:{uid}({user_name})") 117 | await Ban.unban(uid) 118 | await Text(f"已永久封禁用户:{uid}({user_name})").send(at_sender=True) 119 | 120 | await ban.finish() 121 | 122 | 123 | @ban.assign("list") 124 | async def list(): 125 | ban_users = await Ban.all() 126 | if ban_users: 127 | message = "\n".join( 128 | [ 129 | f"ID: {ban_user.id}\n" 130 | f"用户ID: {ban_user.uid}\n" 131 | f"封禁开始时间: {ban_user.ban_time}\n" 132 | f"封禁时长: {ban_user.duration}\n" 133 | for ban_user in ban_users 134 | ] 135 | ) 136 | else: 137 | message = "当前还没有被封禁用户呢" 138 | await Text(message).finish(at_sender=True) 139 | 140 | 141 | def convert_to_seconds(hours: int, minutes: int): 142 | total_minutes = hours * 60 + minutes 143 | total_seconds = total_minutes * 60 144 | return total_seconds 145 | -------------------------------------------------------------------------------- /sora/plugins/control/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import asyncio 3 | import platform 4 | 5 | from nonebot import require 6 | from nonebot.rule import to_me 7 | 8 | require("nonebot_plugin_saa") 9 | require("nonebot_plugin_alconna") 10 | from nonebot_plugin_saa import Text 11 | from arclet.alconna.args import Args 12 | from arclet.alconna.core import Alconna 13 | from arclet.alconna.typing import CommandMeta 14 | from nonebot_plugin_alconna.uniseg import UniMessage 15 | from nonebot_plugin_alconna import Match, MultiVar, AlconnaMatcher, on_alconna 16 | 17 | from sora.config import bot_config 18 | from sora.version import __version__ 19 | from sora.permission import ADMIN, HELPER 20 | from sora.utils.helpers import HandleCancellation 21 | from sora.utils.update import CheckUpdate, update 22 | 23 | nickname = list(bot_config.nickname) 24 | 25 | update_cmd = on_alconna( 26 | Alconna( 27 | "更新", 28 | meta=CommandMeta( 29 | description="更新林汐", 30 | usage="@bot /更新", 31 | example="@bot /更新", 32 | compact=True, 33 | ), 34 | ), 35 | permission=ADMIN | HELPER, 36 | rule=to_me(), 37 | priority=1, 38 | block=True, 39 | ) 40 | check_update_cmd = on_alconna( 41 | Alconna( 42 | "检查更新", 43 | meta=CommandMeta( 44 | description="检查更新", 45 | usage="@bot /检查林汐版本", 46 | example="@bot /检查更新", 47 | compact=True, 48 | ), 49 | ), 50 | permission=ADMIN | HELPER, 51 | rule=to_me(), 52 | priority=1, 53 | block=True, 54 | ) 55 | 56 | reboot = on_alconna( 57 | Alconna( 58 | "reboot", 59 | meta=CommandMeta( 60 | description="重启林汐", 61 | usage="@bot /reboot", 62 | example="@bot /reboot", 63 | compact=True, 64 | ), 65 | ), 66 | permission=ADMIN | HELPER, 67 | rule=to_me(), 68 | priority=1, 69 | block=True, 70 | ) 71 | run_cmd = on_alconna( 72 | Alconna( 73 | "cmd", 74 | Args["cmd?", MultiVar(str, "*")], 75 | meta=CommandMeta( 76 | description="运行终端命令", 77 | usage="@bot /cmd", 78 | example="@bot /cmd", 79 | compact=True, 80 | ), 81 | ), 82 | permission=ADMIN, 83 | rule=to_me(), 84 | priority=1, 85 | block=True, 86 | ) 87 | 88 | 89 | @update_cmd.handle() 90 | async def _(): 91 | await update_cmd.send(f"{nickname[0]}开始更新", at_sender=True) 92 | result = await update() 93 | await Text(result).send(at_sender=True) 94 | await update_cmd.finish() 95 | 96 | 97 | @check_update_cmd.handle() 98 | async def _(): 99 | latest_version, update_time = await CheckUpdate.show_latest_version() 100 | if latest_version and update_time: 101 | if latest_version != __version__: 102 | await Text(f"新版本已发布, 请更新\n最新版本: {latest_version} 更新时间: {update_time}").send( 103 | at_sender=True 104 | ) 105 | else: 106 | await Text("当前已是最新版本").send(at_sender=True) 107 | await check_update_cmd.finish() 108 | 109 | 110 | @reboot.handle() 111 | async def _(): 112 | await Text(f"开始重启{nickname[0]}..请稍等...").send(at_sender=True) 113 | if str(platform.system()).lower() == "windows": 114 | import sys 115 | 116 | python = sys.executable 117 | os.execl(python, python, *sys.argv) 118 | else: 119 | os.system("./restart.sh") 120 | 121 | 122 | @run_cmd.handle() 123 | async def _(matcher: AlconnaMatcher, cmd: Match[tuple[str, ...]]): 124 | if cmd.available: 125 | cmd_result = " ".join(cmd.result) 126 | matcher.set_path_arg("cmd", cmd_result) 127 | 128 | 129 | @run_cmd.got_path( 130 | "cmd", 131 | prompt=UniMessage.template("{:At(user, $event.get_user_id())} 请输入你要运行的命令"), 132 | parameterless=[HandleCancellation("已取消")], 133 | ) 134 | async def _(cmd: str): 135 | await Text(f"开始执行 {cmd}").send(at_sender=True) 136 | p = await asyncio.subprocess.create_subprocess_shell( 137 | cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE 138 | ) 139 | stdout, stderr = await p.communicate() 140 | try: 141 | result = (stdout or stderr).decode("utf-8") 142 | except Exception: 143 | result = str(stdout or stderr) 144 | await Text(f"{cmd}\n运行结果:\n{result}").send(at_sender=True) 145 | await run_cmd.finish() 146 | -------------------------------------------------------------------------------- /sora/plugins/control/data_source.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | from pathlib import Path 4 | 5 | from sora.log import logger 6 | from sora.config import bot_config 7 | from sora.hook import on_bot_connect 8 | 9 | 10 | @on_bot_connect 11 | async def remind(): 12 | if str(platform.system()).lower() != "windows": 13 | restart = Path() / "restart.sh" 14 | if not restart.exists(): 15 | with open(restart, "w", encoding="utf8") as f: 16 | f.write( 17 | "pid=$(netstat -tunlp | grep " 18 | + str(bot_config.port) 19 | + " | awk '{print $7}')\n" 20 | "pid=${pid%/*}\n" 21 | "kill -9 $pid\n" 22 | "sleep 3\n" 23 | "python3 bot.py" 24 | ) 25 | os.system("chmod +x ./restart.sh") 26 | logger.info("配置", "已自动生成 restart.sh 重启脚本,请检查脚本是否与本地指令符合..") 27 | -------------------------------------------------------------------------------- /sora/plugins/echo.py: -------------------------------------------------------------------------------- 1 | from nonebot import require 2 | 3 | require("nonebot_plugin_alconna") 4 | from nonebot_plugin_alconna import funcommand 5 | 6 | from sora.permission import ADMIN, HELPER 7 | 8 | 9 | @funcommand(permission=ADMIN | HELPER) 10 | async def echo(msg: str): 11 | return msg 12 | -------------------------------------------------------------------------------- /sora/plugins/help/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | from collections import deque 3 | 4 | from nonebot import require 5 | from nonebot.rule import to_me 6 | 7 | require("nonebot_plugin_saa") 8 | require("nonebot_plugin_alconna") 9 | from nonebot_plugin_saa import Text 10 | from arclet.alconna.base import Option 11 | from arclet.alconna.core import Alconna 12 | from arclet.alconna.args import Arg, Args 13 | from arclet.alconna.arparma import Arparma 14 | from arclet.alconna.typing import CommandMeta 15 | from arclet.alconna.manager import command_manager 16 | from nonebot_plugin_alconna import Match, on_alconna, store_true 17 | from nonebot_plugin_alconna.extension import Extension, add_global_extension 18 | 19 | from .config import Config 20 | 21 | config = Config.load_config() 22 | 23 | _help = Alconna( 24 | "help", 25 | Args["plugin?", str], 26 | Option("--hide", action=store_true, default=False, help_text="是否列出隐藏命令"), 27 | meta=CommandMeta( 28 | description="获取林汐帮助文档", 29 | usage="@bot /help", 30 | example="@bot /help", 31 | compact=True, 32 | ), 33 | ) 34 | 35 | _statis = Alconna( 36 | "statis", 37 | Arg("type", Literal["show", "most", "least"], "most"), 38 | Args["count", int, config.message_count], 39 | meta=CommandMeta( 40 | description="命令统计", 41 | usage="@bot /statis most", 42 | example="@bot /statis most", 43 | compact=True, 44 | ), 45 | ) 46 | 47 | record = deque(maxlen=256) 48 | 49 | 50 | class HelperExtension(Extension): 51 | @property 52 | def priority(self) -> int: 53 | return 5 54 | 55 | @property 56 | def id(self) -> str: 57 | return "SoraBot:HelperExtension" 58 | 59 | async def parse_wrapper(self, bot, state, event, res: Arparma) -> None: 60 | if res.source != _statis.path: 61 | record.append((res.source, res.origin)) 62 | 63 | 64 | add_global_extension(HelperExtension()) 65 | 66 | help_cmd = on_alconna(_help, auto_send_output=True, rule=to_me()) 67 | statis_cmd = on_alconna(_statis, auto_send_output=True, rule=to_me()) 68 | statis_cmd.shortcut("消息统计", {"args": ["show"], "prefix": True}) 69 | statis_cmd.shortcut("命令统计", {"args": ["most"], "prefix": True}) 70 | 71 | 72 | @help_cmd.handle() 73 | async def _(plugin: Match[str]): 74 | if plugin.available: 75 | plugin_help = command_manager.command_help(plugin.result) 76 | if plugin_help is None: 77 | await Text("唔...没有找到帮助呢").finish(at_sender=True) 78 | else: 79 | await Text(plugin_help).finish(at_sender=True) 80 | 81 | await Text( 82 | command_manager.all_command_help(show_index=True, namespace="Alconna") 83 | ).send(at_sender=True) 84 | await help_cmd.finish() 85 | 86 | 87 | @statis_cmd.assign("type", "show") 88 | async def statis_cmd_show(count: int): 89 | if not record: 90 | await statis_cmd.finish("暂无命令记录") 91 | 92 | await statis_cmd.finish( 93 | "最近的命令记录为:\n" 94 | + "\n".join([f"[{i}]: {record[i][1]}" for i in range(min(count, len(record)))]) 95 | ) 96 | 97 | 98 | @statis_cmd.assign("type", "most") 99 | async def statis_cmd_most(arp: Arparma): 100 | if not record: 101 | await statis_cmd.finish("暂无命令记录") 102 | 103 | length = len(record) 104 | table = {} 105 | for i, r in enumerate(record): 106 | source = r[0] 107 | if source not in table: 108 | table[source] = 0 109 | table[source] += i / length 110 | sort = sorted(table.items(), key=lambda x: x[1], reverse=True) 111 | sort = sort[: arp.query[int]("count")] 112 | await statis_cmd.finish( 113 | "以下按照命令使用频率排序\n" + "\n".join(f"[{k}] {v[0]}" for k, v in enumerate(sort)) 114 | ) 115 | 116 | 117 | @statis_cmd.assign("type", "least") 118 | async def statis_cmd_least(arp: Arparma): 119 | if not record: 120 | await statis_cmd.finish("暂无命令记录") 121 | length = len(record) 122 | table = {} 123 | for i, r in enumerate(record): 124 | source = r[0] 125 | if r[source] not in table: 126 | table[source] = 0 127 | table[source] += i / length 128 | sort = sorted(table.items(), key=lambda x: x[1], reverse=False) 129 | sort = sort[: arp.query[int]("count")] 130 | await statis_cmd.finish( 131 | "以下按照命令使用频率排序\n" + "\n".join(f"[{k}] {v[0]}" for k, v in enumerate(sort)) 132 | ) 133 | -------------------------------------------------------------------------------- /sora/plugins/help/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from sora.config import BaseConfig 4 | 5 | 6 | class Config(BaseConfig): 7 | index: bool = False 8 | """是否展示索引""" 9 | 10 | max_length: int = -1 11 | """单个页面展示的最大长度""" 12 | 13 | message_count: int = Field(10, ge=1, le=120) 14 | """统计命令显示的消息数量""" 15 | -------------------------------------------------------------------------------- /sora/plugins/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | from . import check_ban as check_ban 2 | from . import user_exist as user_exist 3 | from . import level_upgrade as level_upgrade 4 | -------------------------------------------------------------------------------- /sora/plugins/hooks/check_ban.py: -------------------------------------------------------------------------------- 1 | from nonebot.adapters import Bot, Event 2 | from nonebot.message import run_preprocessor 3 | from nonebot.exception import IgnoredException 4 | 5 | from sora.log import logger 6 | from sora.database import Ban, User 7 | 8 | 9 | @run_preprocessor 10 | async def _(bot: Bot, event: Event): 11 | platform = bot.adapter.get_name() 12 | pid = event.get_user_id() 13 | if await User.check_exists(platform, pid): 14 | uid = (await User.get_user_by_pid(pid)).uid 15 | is_ban = await Ban.is_banned(uid) 16 | if is_ban: 17 | logger.debug(f"用户 {uid} 处于黑名单中") 18 | raise IgnoredException("用户处于黑名单中") 19 | -------------------------------------------------------------------------------- /sora/plugins/hooks/level_upgrade.py: -------------------------------------------------------------------------------- 1 | from nonebot import require 2 | from nonebot.params import Depends 3 | from tortoise.expressions import F 4 | from nonebot.message import run_postprocessor 5 | from nonebot.internal.adapter import Bot, Event 6 | 7 | require("nonebot_plugin_saa") 8 | from nonebot_plugin_saa import MessageFactory, PlatformTarget, extract_target 9 | 10 | from sora.database import User 11 | from sora.utils.annotated import UserInfo 12 | from sora.plugins.sign.config import GameConfig 13 | from sora.plugins.sign.utils import calc_exp_threshold 14 | 15 | level_config = (GameConfig.load_config("game")).level 16 | 17 | 18 | @run_postprocessor 19 | async def level_up( 20 | bot: Bot, 21 | event: Event, 22 | userInfo: UserInfo, 23 | target: PlatformTarget = Depends(extract_target), 24 | ): 25 | if not await User.check_exists(bot.type, event.get_user_id()): 26 | return 27 | 28 | uid = userInfo.uid 29 | user_level = userInfo.level 30 | user_exp = userInfo.exp 31 | exp_threshold = calc_exp_threshold(user_level) 32 | 33 | if user_exp >= exp_threshold: 34 | await User.filter(uid=uid).update( 35 | level=F("level") + 1, exp=F("exp") - exp_threshold 36 | ) 37 | await MessageFactory( 38 | f"🎉 经验值溢出,等级 + 1!\n当前等级:{user_level + 1},经验值:{user_exp - exp_threshold}" 39 | ).send_to(target, bot) 40 | -------------------------------------------------------------------------------- /sora/plugins/hooks/user_exist.py: -------------------------------------------------------------------------------- 1 | """ 2 | 该钩子为事件预处理钩子 3 | 用来在处理事件前先判断用户是否存在 4 | 如果不存在则自动为其注册账号 5 | """ 6 | import inspect 7 | 8 | from nonebot import require 9 | from nonebot.params import Depends 10 | from nonebot.internal.adapter import Bot 11 | from nonebot.message import run_preprocessor 12 | from nonebot.adapters.qq.exception import AuditException 13 | 14 | require("nonebot_plugin_saa") 15 | require("nonebot_plugin_userinfo") 16 | from nonebot_plugin_userinfo import UserInfo, EventUserInfo 17 | from nonebot_plugin_saa import MessageFactory, PlatformTarget, extract_target 18 | 19 | from sora.log import logger 20 | from sora.database import Bind, Sign, User 21 | from sora.utils.api import generate_id, random_text 22 | 23 | 24 | @run_preprocessor 25 | async def _( 26 | bot: Bot, 27 | user: UserInfo = EventUserInfo(), 28 | target: PlatformTarget = Depends(extract_target), 29 | ): 30 | uid = generate_id() 31 | pid = user.user_id 32 | user_name = user.user_name 33 | platform = bot.adapter.get_name() 34 | exist = await User.check_exists(platform, pid) 35 | 36 | if not exist: 37 | if await User.check_username(user_name): 38 | user_name += random_text(2) 39 | 40 | userinfo: User = await User.create_user(uid, user_name) 41 | await Sign.create(uid=uid) 42 | await Bind.create(uid=uid, platform=platform, pid=pid, info=userinfo) 43 | 44 | logger.success(f"用户 {user_name}({uid}) 账号信息初始化成功!") 45 | 46 | try: 47 | await MessageFactory( 48 | inspect.cleandoc( 49 | f""" 50 | 第一次使用林汐? 51 | 我们已为您注册全新的账户! 52 | - 用户名:{user_name}(lv.1) 53 | - ID:{uid} 54 | 顺便奖励您 50 枚硬币。 55 | 『提示』如果您在其它平台已有账户,可发送 /bind 指令绑定! 56 | """ 57 | ) 58 | ).send_to(target, bot) 59 | except AuditException: 60 | pass 61 | -------------------------------------------------------------------------------- /sora/plugins/network/__init__.py: -------------------------------------------------------------------------------- 1 | """网络工具""" 2 | 3 | from dns import resolver 4 | from nonebot import require 5 | from nonebot.rule import to_me 6 | 7 | require("nonebot_plugin_saa") 8 | require("nonebot_plugin_alconna") 9 | from nonebot_plugin_saa import Text 10 | from arclet.alconna.args import Args 11 | from arclet.alconna.core import Alconna 12 | from arclet.alconna.typing import CommandMeta 13 | from nonebot_plugin_alconna import on_alconna 14 | 15 | dns = on_alconna( 16 | Alconna( 17 | "dns", 18 | Args["domain#域名", "url"]["rdtype?#记录名", "str", "A"], 19 | meta=CommandMeta( 20 | description="DNS 查询", 21 | usage="@bot /dns <域名> [记录名]", 22 | example="@bot /dns google.com", 23 | compact=True, 24 | ), 25 | ), 26 | rule=to_me(), 27 | priority=99, 28 | block=True, 29 | ) 30 | 31 | 32 | async def _(domain: str, rdtype: str): 33 | try: 34 | answers = resolver.resolve(domain, rdtype) 35 | result = answers.rrset 36 | if result is None: 37 | return 38 | for answer in result: 39 | await Text(answer.to_text()).finish(at_sender=True) 40 | except resolver.NXDOMAIN: 41 | await Text("No DNS record found for the domain.").finish(at_sender=True) 42 | except resolver.NoAnswer: 43 | await Text("No A record found for the domain.").finish(at_sender=True) 44 | -------------------------------------------------------------------------------- /sora/plugins/pic/__init__.py: -------------------------------------------------------------------------------- 1 | from . import pic_jk as pic_jk 2 | from . import pic_cos as pic_cos 3 | from . import pic_legs as pic_legs 4 | from . import pic_setu as pic_setu 5 | -------------------------------------------------------------------------------- /sora/plugins/pic/pic_cos.py: -------------------------------------------------------------------------------- 1 | from nonebot import require 2 | 3 | require("nonebot_plugin_saa") 4 | require("nonebot_plugin_alconna") 5 | 6 | from nonebot_plugin_saa import Text, Image 7 | from nonebot_plugin_alconna import Alconna, CommandMeta, on_alconna 8 | 9 | from sora.log import logger 10 | 11 | URL = "https://picture.yinux.workers.dev" 12 | 13 | cos = on_alconna( 14 | Alconna( 15 | "cos", 16 | meta=CommandMeta( 17 | description="三次元也不戳,嘿嘿嘿", 18 | usage="/cos", 19 | example="/cos", 20 | compact=True, 21 | ), 22 | ), 23 | priority=50, 24 | block=True, 25 | ) 26 | cos.shortcut("coser", prefix=True) 27 | 28 | 29 | @cos.handle() 30 | async def _(): 31 | try: 32 | await Image(URL).send(at_sender=True) 33 | except Exception as e: 34 | logger.error(f"{e}") 35 | await Text("你cos给我看!").send(at_sender=True) 36 | await cos.finish() 37 | -------------------------------------------------------------------------------- /sora/plugins/pic/pic_jk.py: -------------------------------------------------------------------------------- 1 | from nonebot import require 2 | 3 | from sora.utils.requests import AsyncHttpx 4 | 5 | require("nonebot_plugin_saa") 6 | require("nonebot_plugin_alconna") 7 | 8 | from nonebot_plugin_saa import Image 9 | from arclet.alconna.core import Alconna 10 | from arclet.alconna.typing import CommandMeta 11 | from nonebot_plugin_alconna import on_alconna 12 | 13 | URL = "https://api.sevin.cn/api/jk.php" 14 | 15 | jk = on_alconna( 16 | Alconna( 17 | "jk", 18 | meta=CommandMeta( 19 | description="谁不喜欢看Jk呢?", 20 | usage="/jk", 21 | example="/jk", 22 | compact=True, 23 | ), 24 | ), 25 | priority=50, 26 | block=True, 27 | ) 28 | jk.shortcut("看裙", prefix=True) 29 | 30 | 31 | @jk.handle() 32 | async def _(): 33 | res = await AsyncHttpx.get(URL) 34 | data = res.text.strip() 35 | await Image(data).finish(at_sender=True) 36 | -------------------------------------------------------------------------------- /sora/plugins/pic/pic_legs.py: -------------------------------------------------------------------------------- 1 | from nonebot import require 2 | 3 | from sora.utils.requests import AsyncHttpx 4 | 5 | require("nonebot_plugin_saa") 6 | require("nonebot_plugin_alconna") 7 | 8 | from nonebot_plugin_saa import Image 9 | from arclet.alconna.core import Alconna 10 | from arclet.alconna.typing import CommandMeta 11 | from nonebot_plugin_alconna import on_alconna 12 | 13 | URL = "http://81.70.100.130/api/tu.php" 14 | 15 | legs = on_alconna( 16 | Alconna( 17 | "玉足", 18 | meta=CommandMeta( 19 | description="什么都玉足只会害了你", 20 | usage="/玉足", 21 | example="/玉足", 22 | compact=True, 23 | ), 24 | ), 25 | priority=50, 26 | block=True, 27 | ) 28 | legs.shortcut("看腿", prefix=True) 29 | 30 | 31 | @legs.handle() 32 | async def _(): 33 | res = await AsyncHttpx.get(URL) 34 | data = res.text.strip() 35 | await Image(data).finish(at_sender=True) 36 | -------------------------------------------------------------------------------- /sora/plugins/pic/pic_setu.py: -------------------------------------------------------------------------------- 1 | from nonebot import require 2 | from httpx import ConnectError 3 | 4 | require("nonebot_plugin_saa") 5 | require("nonebot_plugin_alconna") 6 | 7 | from arclet.alconna.args import Args 8 | from arclet.alconna.base import Option 9 | from arclet.alconna.core import Alconna 10 | from arclet.alconna.typing import MultiVar, CommandMeta 11 | from nonebot_plugin_alconna import Match, Query, on_alconna, store_true 12 | from nonebot_plugin_saa import Text, Image, MessageFactory, AggregatedMessageFactory 13 | 14 | from sora.log import logger 15 | from sora.utils.requests import AsyncHttpx 16 | 17 | URL: str = "https://api.lolicon.app/setu/v2?r18={r18}&num={num}&tag={tag}" 18 | 19 | setu = on_alconna( 20 | Alconna( 21 | "setu", 22 | Args["count", int, 1], 23 | Option("-r|--r18", action=store_true, default=False, help_text="是否开启 R18 模式"), 24 | Option("-t|--tags", Args["tags", MultiVar(str, "*")], help_text="指定标签"), 25 | meta=CommandMeta( 26 | description="谁不喜欢看色图呢?", 27 | usage="/setu", 28 | example="/色图", 29 | compact=True, 30 | ), 31 | ), 32 | priority=50, 33 | block=True, 34 | ) 35 | 36 | 37 | def wrapper(slot: int | str, content: str | None) -> str | None: 38 | if slot == 0: 39 | if not content: 40 | return "1" 41 | if content == "点": 42 | import random 43 | 44 | return str(random.randint(1, 5)) 45 | return content 46 | 47 | 48 | setu.shortcut( 49 | r"(?:要|我要|给我|来|抽)(点|\d*)(?:张|个|份|幅)?(?:色|涩|瑟)图", 50 | command="setu {0} -t", 51 | fuzzy=True, 52 | wrapper=wrapper, 53 | ) 54 | 55 | setu.shortcut( 56 | r"(?:要|我要|给我|来|抽)(点|\d*)(?:张|个|份|幅)?(.+?)的?(?:色|涩|瑟)图", 57 | command="setu {0}", 58 | arguments=["tags", "{1}"], 59 | fuzzy=True, 60 | wrapper=wrapper, 61 | ) 62 | 63 | 64 | @setu.handle() 65 | async def setu_( 66 | count: int, tags: Match[tuple[str, ...]], r18: Query[bool] = Query("r18.value") 67 | ): 68 | tags_result = "|".join(tags.result) if tags.available else "" 69 | r18_result = 1 if r18.result else 0 70 | 71 | url = URL.format(r18=str(r18_result), num=str(count), tag=tags_result) 72 | 73 | try: 74 | messages = [] 75 | 76 | res = await AsyncHttpx.get(url) 77 | parsed_data = res.json() 78 | 79 | if count == 1: 80 | title = parsed_data["data"][0]["title"] 81 | original = parsed_data["data"][0]["urls"]["original"] 82 | await MessageFactory([Text(title), Image(original)]).finish(at_sender=True) 83 | 84 | for item in parsed_data["data"]: 85 | title = item["title"] 86 | original = item["urls"]["original"] 87 | 88 | message = MessageFactory([Text(title), Image(original)]) 89 | messages.append(message) 90 | 91 | await AggregatedMessageFactory(messages).send() 92 | 93 | except ConnectError: 94 | logger.error("网络错误") 95 | -------------------------------------------------------------------------------- /sora/plugins/sign/__init__.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from datetime import date, datetime 3 | 4 | from nonebot import require 5 | from nonebot.rule import to_me 6 | from tortoise.expressions import F 7 | from nonebot.internal.adapter import Bot 8 | 9 | require("nonebot_plugin_saa") 10 | require("nonebot_plugin_alconna") 11 | 12 | from nonebot_plugin_saa import Text 13 | from arclet.alconna.args import Args 14 | from arclet.alconna.base import Option 15 | from arclet.alconna.core import Alconna 16 | from arclet.alconna.typing import CommandMeta 17 | from nonebot_plugin_alconna import Match, on_alconna 18 | from nonebot_plugin_alconna.uniseg.segment import At 19 | 20 | from sora.utils.annotated import UserInfo 21 | from sora.database.models import Sign, User 22 | 23 | from .config import GameConfig 24 | 25 | sign_config = (GameConfig.load_config("game")).sign 26 | 27 | sign = on_alconna( 28 | Alconna( 29 | "sign", 30 | Option("info", Args["target?", At | int], alias={"信息"}, help_text="签到信息"), 31 | meta=CommandMeta( 32 | description="每日打卡", 33 | usage="签到:@bot /签到\n签到信息:@bot /签到信息 [At]\n当日签到排行:@bot /签到排行", 34 | example="@bot /签到", 35 | compact=True, 36 | ), 37 | ), 38 | priority=20, 39 | rule=to_me(), 40 | ) 41 | 42 | sign.shortcut("签到", prefix=True) 43 | 44 | 45 | @sign.assign("$main") 46 | async def _(user: UserInfo): 47 | uid = user.uid 48 | signInfo = await Sign.get_sign_info(uid) 49 | 50 | if signInfo is None: 51 | last_sign = None 52 | total_days = continuous_days = 0 53 | else: 54 | last_sign = signInfo.last_sign 55 | total_days = signInfo.total_days 56 | continuous_days = signInfo.continuous_days 57 | 58 | if last_sign is not None and last_sign.date() == date.today(): 59 | await Text("您今日已经签到过了,不可重复签到").finish(at_sender=True) 60 | 61 | if last_sign is not None and abs((date.today() - last_sign.date()).days) == 1: 62 | continuous_days += 1 63 | extra_coin = int(continuous_days * 1.5) 64 | msg = f"你当前连续签到 {str(continuous_days)} 天,额外奖励 {extra_coin} 枚硬币。" 65 | await User.reward(uid, reward={"coin": extra_coin}) 66 | await Sign.filter(uid=uid).update(continuous_days=F("continuous_days") + 1) 67 | else: 68 | msg = "你当前连续签到 0 天," 69 | extra_coin = 0 70 | await Sign.filter(uid=uid).update(continuous_days=0) 71 | 72 | amount = await User.reward(uid, reward=sign_config.rewards) 73 | await Sign.filter(uid=uid).update( 74 | total_days=total_days + 1, last_sign=datetime.now() 75 | ) 76 | 77 | sign_coin = amount["coin"] + extra_coin 78 | sign_exp = amount["exp"] 79 | sign_favor = amount["favor"] 80 | 81 | await Text( 82 | inspect.cleandoc( 83 | f""" 84 | 签到成功! 85 | {msg}历史最高 {str(total_days + 1)} 天。 86 | —————————————— 87 | ۞≡==——☚◆☛——==≡۞ 88 | ➢[金币+{sign_coin}] 89 | —— Now: {user.coin + sign_coin} 90 | ➢[好感+{sign_favor}] 91 | —— Now: {user.favor + sign_favor} 92 | ➢[经验+{sign_exp}] 93 | —— Now: {user.exp + sign_exp} 94 | ۞≡==——☚◆☛——==≡۞ 95 | ————————————— 96 | """ 97 | ) 98 | ).send(at_sender=True) 99 | 100 | 101 | @sign.assign("info") 102 | async def _(bot: Bot, target: Match[At | int], userInfo: UserInfo): 103 | if target.available: 104 | if isinstance(target.result, At): 105 | pid = target.result.target 106 | if not await User.check_exists(bot.adapter.get_name(), pid): 107 | await Text("您@的用户还未注册喔").finish(at_sender=True) 108 | target_id = (await User.get_user_by_pid(pid)).uid 109 | target_user = await User.get_user_by_uid(target_id) 110 | else: 111 | target_id = str(target.result) 112 | target_user = await User.get_user_by_uid(target_id) 113 | call = target_user.user_name 114 | else: 115 | target_id = userInfo.uid 116 | call = "您" 117 | 118 | signInfo = await Sign.get_sign_info(target_id) 119 | 120 | if signInfo is None: 121 | await Text("你还没有签到过喔").finish(at_sender=True) 122 | 123 | await Text( 124 | f"{call}当前连续签到 {signInfo.continuous_days} 天,累计 {signInfo.total_days} 天" 125 | ).send(at_sender=True) 126 | await sign.finish() 127 | -------------------------------------------------------------------------------- /sora/plugins/sign/config.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from pydantic import field_validator 4 | 5 | from sora.log import logger 6 | from sora.config import BaseConfig 7 | 8 | 9 | class LevelConfig(BaseConfig): 10 | base_exp: int = 150 11 | """初始经验阈值""" 12 | max_level: int = 70 13 | """最大等级""" 14 | cardinality: float = 1.1 15 | """经验系数""" 16 | 17 | 18 | class SignConfig(BaseConfig): 19 | """签到配置""" 20 | 21 | rewards: dict[str, Any] = {"coin": [10, 20], "exp": 320, "favor": 0} 22 | 23 | @field_validator("rewards", mode="before") 24 | def replenish(cls, v): 25 | logger.info("运行 replenish") 26 | if missing_keys := {"coin", "exp", "favor"} - v.keys(): 27 | logger.info(f"Missing reward key: {missing_keys}") 28 | for key in missing_keys: 29 | v[key] = 0 30 | return v 31 | return v 32 | 33 | # @validator("rewards") 34 | # def check_rewards(cls, v): 35 | # logger.info("运行 check_rewards") 36 | # if extra_key := v.keys() - {"coin", "exp", "favor"}: 37 | # raise ValueError(f"Invalid reward key: {extra_key}") 38 | # return v 39 | 40 | 41 | class GameConfig(BaseConfig): 42 | level: LevelConfig = LevelConfig() 43 | sign: SignConfig = SignConfig() 44 | -------------------------------------------------------------------------------- /sora/plugins/sign/utils.py: -------------------------------------------------------------------------------- 1 | from .config import GameConfig 2 | 3 | level_config = (GameConfig.load_config("game")).level 4 | 5 | 6 | def calc_exp_threshold(level: int) -> int: 7 | """ 8 | 计算经验阈値 9 | :param level:用户当前的等级 10 | :return: threshold 11 | """ 12 | base_exp = level_config.base_exp 13 | cardinality = level_config.cardinality 14 | threshold = round(int(base_exp * (cardinality**level)) / 10) * 10 15 | return threshold 16 | 17 | 18 | def calc_next_exp_threshold(level: int) -> int: 19 | """计算下一等级的经验阈值""" 20 | threshold = calc_exp_threshold(level + 1) 21 | return threshold 22 | 23 | 24 | def generate_progress_bar(user_level: int, user_exp: int, bar_length: int = 20): 25 | current_max_exp = calc_exp_threshold(user_level) 26 | next_max_exp = calc_next_exp_threshold(user_level) 27 | 28 | progress_bar = "" 29 | 30 | for i in range(int(float(current_max_exp / next_max_exp) * bar_length)): 31 | progress_bar = progress_bar + "|" 32 | for i in range(bar_length - len(progress_bar)): 33 | progress_bar = progress_bar + " " 34 | progress_bar = "[" + progress_bar + "]" 35 | progress_text = f"Lv.{user_level + 1}: {user_exp} / {next_max_exp}" 36 | return progress_bar, progress_text 37 | -------------------------------------------------------------------------------- /sora/plugins/status/__init__.py: -------------------------------------------------------------------------------- 1 | """运行状态""" 2 | 3 | from nonebot import require 4 | from nonebot.rule import to_me 5 | 6 | require("nonebot_plugin_alconna") 7 | from nonebot_plugin_alconna import Command 8 | from nonebot_plugin_alconna.uniseg import UniMessage 9 | 10 | from .drawer import draw 11 | 12 | status = ( 13 | Command("status", help_text="查看林汐运行状态") 14 | .usage("@bot /status\n@bot /状态") 15 | .action(lambda: UniMessage.image(raw=draw())) 16 | .build(rule=to_me()) 17 | ) 18 | status.shortcut("状态", {"prefix": True}) 19 | status.shortcut("运行状态", {"prefix": True}) 20 | -------------------------------------------------------------------------------- /sora/plugins/status/color.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, TypeAlias 2 | 3 | Color: TypeAlias = Literal[ 4 | "cpu", "ram", "swap", "disk", "nickname", "details", "transparent" 5 | ] 6 | 7 | 8 | cpu_color: tuple[int, int, int, int] = (84, 173, 255, 255) 9 | ram_color: tuple[int, int, int, int] = (255, 179, 204, 255) 10 | swap_color: tuple[int, int, int, int] = (251, 170, 147, 255) 11 | disk_color: tuple[int, int, int, int] = (184, 170, 159, 255) 12 | transparent_color: tuple[int, int, int, int] = (0, 0, 0, 0) 13 | details_color: tuple[int, int, int, int] = (184, 170, 159, 255) 14 | nickname_color: tuple[int, int, int, int] = (84, 173, 255, 255) 15 | 16 | 17 | def get_color(color: Color) -> tuple[int, int, int, int]: 18 | return globals()[f"{color}_color"] 19 | -------------------------------------------------------------------------------- /sora/plugins/status/drawer.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from io import BytesIO 3 | 4 | import cpuinfo 5 | import nonebot 6 | from PIL import Image, ImageDraw, ImageFont 7 | 8 | from sora.config import bot_config 9 | from sora.version import __version__ 10 | 11 | from .model import get_status_info 12 | from .utils import truncate_string 13 | from .path import ( 14 | bg_img_path, 15 | adlam_font_path, 16 | baotu_font_path, 17 | marker_img_path, 18 | spicy_font_path, 19 | ) 20 | from .color import ( 21 | cpu_color, 22 | ram_color, 23 | disk_color, 24 | swap_color, 25 | details_color, 26 | nickname_color, 27 | transparent_color, 28 | ) 29 | 30 | system = platform.uname() 31 | nickname = list(bot_config.nickname)[0] if bot_config.nickname else "unknown" 32 | 33 | adlam_fnt = ImageFont.truetype(str(adlam_font_path), 36) 34 | spicy_fnt = ImageFont.truetype(str(spicy_font_path), 38) 35 | baotu_fnt = ImageFont.truetype(str(baotu_font_path), 64) 36 | 37 | 38 | def draw() -> bytes: 39 | """绘图""" 40 | 41 | loaded_plugins = nonebot.get_loaded_plugins() 42 | 43 | with Image.open(bg_img_path).convert("RGBA") as base: 44 | img = Image.new("RGBA", base.size, (0, 0, 0, 0)) 45 | marker = Image.open(marker_img_path) 46 | 47 | cpu, ram, swap, disk = get_status_info() 48 | 49 | cpu_info = f"{cpu.usage}% - {cpu.freq}Ghz [{cpu.core}core]" 50 | ram_info = f"{ram.usage} / {ram.total} GB" 51 | swap_info = f"{swap.usage} / {swap.total} GB" 52 | disk_info = f"{disk.usage} / {disk.total} GB" 53 | 54 | content = ImageDraw.Draw(img) 55 | content.text((103, 581), nickname, font=baotu_fnt, fill=nickname_color) 56 | content.text((251, 772), cpu_info, font=spicy_fnt, fill=cpu_color) 57 | content.text((251, 927), ram_info, font=spicy_fnt, fill=ram_color) 58 | content.text((251, 1081), swap_info, font=spicy_fnt, fill=swap_color) 59 | content.text((251, 1235), disk_info, font=spicy_fnt, fill=disk_color) 60 | 61 | content.arc( 62 | (103, 724, 217, 838), 63 | start=-90, 64 | end=(cpu.usage * 3.6), 65 | width=115, 66 | fill=cpu_color, 67 | ) 68 | content.arc( 69 | (103, 878, 217, 992), 70 | start=-90, 71 | end=(ram.usage * 3.6), 72 | width=115, 73 | fill=ram_color, 74 | ) 75 | content.arc( 76 | (103, 1032, 217, 1146), 77 | start=-90, 78 | end=(swap.usage * 3.6), 79 | width=115, 80 | fill=swap_color, 81 | ) 82 | content.arc( 83 | (103, 1186, 217, 1300), 84 | start=-90, 85 | end=(disk.usage * 3.6), 86 | width=115, 87 | fill=disk_color, 88 | ) 89 | 90 | content.ellipse((108, 729, 212, 833), width=105, fill=transparent_color) 91 | content.ellipse((108, 883, 212, 987), width=105, fill=transparent_color) 92 | content.ellipse((108, 1037, 212, 1141), width=105, fill=transparent_color) 93 | content.ellipse((108, 1192, 212, 1295), width=105, fill=transparent_color) 94 | 95 | content.text( 96 | (352, 1378), 97 | f"{truncate_string(cpuinfo.get_cpu_info()['brand_raw'])}", 98 | font=adlam_fnt, 99 | fill=details_color, 100 | ) 101 | content.text( 102 | (352, 1431), 103 | f"{system.system} {system.release}", 104 | font=adlam_fnt, 105 | fill=details_color, 106 | ) 107 | content.text((352, 1484), __version__, font=adlam_fnt, fill=details_color) 108 | content.text( 109 | (352, 1537), 110 | f"{len(loaded_plugins)} loaded", 111 | font=adlam_fnt, 112 | fill=details_color, 113 | ) 114 | 115 | nickname_length = baotu_fnt.getlength(nickname) 116 | img.paste(marker, (103 + int(nickname_length) + 44, 595), marker) 117 | 118 | out = Image.alpha_composite(base, img) 119 | 120 | byte_io = BytesIO() 121 | out.save(byte_io, format="png") 122 | img_bytes = byte_io.getvalue() 123 | 124 | return img_bytes 125 | -------------------------------------------------------------------------------- /sora/plugins/status/model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import psutil 4 | 5 | 6 | @dataclass 7 | class CPUInfo: 8 | core: int 9 | """CPU 物理核心数""" 10 | usage: float 11 | """CPU 占用""" 12 | freq: float 13 | """CPU 的时钟速度(单位:GHz)""" 14 | 15 | @classmethod 16 | def get_cpu_info(cls): 17 | cpu_core = psutil.cpu_count(logical=False) 18 | cpu_usage = psutil.cpu_percent(interval=1) 19 | cpu_freq = round(psutil.cpu_freq().current / 1000, 2) 20 | 21 | return CPUInfo(core=cpu_core, usage=cpu_usage, freq=cpu_freq) 22 | 23 | 24 | @dataclass 25 | class RAMInfo: 26 | """RAM 信息(单位:GB)""" 27 | 28 | total: int 29 | """RAM 总量""" 30 | usage: float 31 | """当前 RAM 占用""" 32 | 33 | @classmethod 34 | def get_ram_info(cls): 35 | ram_total = round(psutil.virtual_memory().total / (1024**3), 2) 36 | ram_usage = round(psutil.virtual_memory().used / (1024**3), 2) 37 | 38 | return RAMInfo(total=ram_total, usage=ram_usage) 39 | 40 | 41 | @dataclass 42 | class SwapMemory: 43 | """Swap 信息(单位:GB)""" 44 | 45 | total: float 46 | """Swap 总量""" 47 | usage: float 48 | """当前 Swap 占用""" 49 | 50 | @classmethod 51 | def get_swap_info(cls): 52 | swap_total = round(psutil.swap_memory().total / (1024**3), 2) 53 | swap_usage = round(psutil.swap_memory().used / (1024**3), 2) 54 | 55 | return SwapMemory(total=swap_total, usage=swap_usage) 56 | 57 | 58 | @dataclass 59 | class DiskInfo: 60 | """硬盘信息""" 61 | 62 | total: float 63 | """硬盘总量""" 64 | usage: float 65 | """当前硬盘占用""" 66 | 67 | @classmethod 68 | def get_disk_info(cls): 69 | disk_total = round(psutil.disk_usage("/").total / (1024**3), 2) 70 | disk_usage = round(psutil.disk_usage("/").used / (1024**3), 2) 71 | 72 | return DiskInfo(total=disk_total, usage=disk_usage) 73 | 74 | 75 | def get_status_info() -> tuple[CPUInfo, RAMInfo, SwapMemory, DiskInfo]: 76 | """获取 `CPU` `RAM` `SWAP` `DISK` 信息""" 77 | cpu_info = CPUInfo.get_cpu_info() 78 | ram_info = RAMInfo.get_ram_info() 79 | swap_info = SwapMemory.get_swap_info() 80 | disk_info = DiskInfo.get_disk_info() 81 | 82 | return cpu_info, ram_info, swap_info, disk_info 83 | -------------------------------------------------------------------------------- /sora/plugins/status/path.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from sora.config import FONT_DIR, IMAGE_DIR 4 | 5 | marker_img_path: Path = IMAGE_DIR / "status" / "marker.png" 6 | bg_img_path: Path = IMAGE_DIR / "status" / "background.png" 7 | 8 | baotu_font_path: Path = FONT_DIR / "baotu.ttf" 9 | spicy_font_path: Path = FONT_DIR / "SpicyRice-Regular.ttf" 10 | adlam_font_path: Path = FONT_DIR / "AdlamDisplay-Regular.ttf" 11 | -------------------------------------------------------------------------------- /sora/plugins/status/utils.py: -------------------------------------------------------------------------------- 1 | def truncate_string(string: str, length: int = 32): 2 | """ 3 | 将字符串截断为给定长度。 4 | 5 | 参数: 6 | string (str):要截断的字符串。 7 | length (int):截断字符串的最大长度。 8 | 9 | 返回: 10 | str:截断的字符串。 11 | """ 12 | if len(string) > length: 13 | return string[: length - 3] + "..." 14 | else: 15 | return string 16 | -------------------------------------------------------------------------------- /sora/plugins/user/__init__.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | import pandas as pd 4 | from nonebot import require 5 | from nonebot.rule import to_me 6 | from nonebot.internal.adapter.bot import Bot 7 | from nonebot.internal.adapter.event import Event 8 | 9 | require("nonebot_plugin_saa") 10 | require("nonebot_plugin_alconna") 11 | 12 | from nonebot_plugin_saa import Text 13 | from arclet.alconna.args import Args 14 | from arclet.alconna.base import Option 15 | from arclet.alconna.core import Alconna 16 | from arclet.alconna.typing import CommandMeta 17 | from nonebot_plugin_alconna import Match, on_alconna 18 | from nonebot_plugin_alconna.uniseg.segment import At 19 | 20 | from sora.log import logger 21 | from sora.utils.api import random_text 22 | from sora.utils.annotated import UserInfo 23 | from sora.database.models import Bind, User 24 | 25 | from .model import token_manager, validate_token 26 | 27 | __usage__ = """ 28 | 绑定:@bot /bind 29 | 获取token:@bot /bind -t [自定义token] 30 | 取消绑定:@bot /bind -r 31 | 绑定列表:@bot /bind -l 32 | 33 | (指令别名:/bind) 34 | (注:所有 token 均为 一次性token,使用过后需重新生成) 35 | """ 36 | 37 | __example__ = """ 38 | 假如您希望在QQ频道绑定群聊中的账号数据,则您应该在群聊中输入 39 | > 用户:@bot /bind -t [可以在此处自定义 token] 40 | > Bot:已为您生成一次性token:sora/123456 41 | 42 | 然后,您需要复制此token,来到QQ频道 43 | > 用户:@bot /bind sora/123456 44 | > Bot:绑定成功 45 | """ 46 | 47 | bind = on_alconna( 48 | Alconna( 49 | "bind", 50 | Args["input_token?", str], 51 | Option("-t|--token", Args["token?", str], help_text="生成token"), 52 | Option( 53 | "-l|--list", Args["target?", At], alias={"列表", "信息"}, help_text="查询绑定信息" 54 | ), 55 | Option("-r|--rebind", alias={"取消"}, help_text="取消绑定"), 56 | meta=CommandMeta( 57 | description="将不同平台的用户数据绑定", 58 | usage=__usage__, 59 | example=__example__, 60 | compact=True, 61 | ), 62 | ), 63 | priority=5, 64 | block=True, 65 | rule=to_me(), 66 | ) 67 | bind.shortcut("绑定", command="bind", prefix=True) 68 | bind.shortcut("rebind", command="bind --rebind", prefix=True) 69 | 70 | user = on_alconna( 71 | Alconna( 72 | "user", 73 | Args["target?", At | int], 74 | meta=CommandMeta( 75 | description="查询用户信息", 76 | usage="@bot /user [@|uid]", 77 | example=""" 78 | @bot /user @Komorebi 79 | @bot /user 123456789 80 | """, 81 | compact=True, 82 | ), 83 | ) 84 | ) 85 | 86 | 87 | @bind.assign("$main") 88 | async def bind_(userInfo: UserInfo, input_token: Match[str]): 89 | if input_token.available: 90 | validation_result = validate_token(input_token.result) 91 | if validation_result.is_valid and validation_result.uid is not None: 92 | await Bind.bind(origin_uid=userInfo.uid, bind_uid=validation_result.uid) 93 | bindUserInfo = await User.get_user_by_uid(validation_result.uid) 94 | await Text( 95 | inspect.cleandoc( 96 | f""" 97 | 绑定成功\n 98 | 已将 {userInfo.user_name}({userInfo.uid}) 与 {bindUserInfo.user_name}({bindUserInfo.uid}) 绑定 99 | """ 100 | ) 101 | ).send(at_sender=True) 102 | logger.debug( 103 | f"{userInfo.user_name}({userInfo.uid}) 与 {bindUserInfo.user_name}({bindUserInfo.uid}) 绑定" 104 | ) 105 | else: 106 | await Text("绑定失败,密钥错误!").send(at_sender=True) 107 | else: 108 | await Text("格式错误。输入 /help 绑定 查看其详细用法").send(at_sender=True) 109 | token_manager.remove_token(validation_result.uid) # type: ignore 110 | 111 | await bind.finish() 112 | 113 | 114 | @bind.assign("token") 115 | async def token_(user: UserInfo, token: Match[str]): 116 | if token.available: 117 | if token.result == "random": 118 | random_token = random_text(15, prefix="Sora/") 119 | token_manager.add_token(user.uid, random_token) 120 | await Text(f"已为您生成一次性token:{random_token}").send(at_sender=True) 121 | else: 122 | token_manager.add_token(user.uid, token.result) 123 | await Text(f"一次性token设置成功:{token.result}").send(at_sender=True) 124 | else: 125 | random_token = random_text(15, prefix="Sora/") 126 | token_manager.add_token(user.uid, random_token) 127 | await Text(f"已为您生成一次性token:{random_token}").send(at_sender=True) 128 | await bind.finish() 129 | 130 | 131 | @bind.assign("list") 132 | async def bind_list_(event: Event, target: Match[At]): 133 | if target.available: 134 | bindInfo = await Bind.get_user_bind(pid=target.result.target) 135 | df = pd.DataFrame(bindInfo) 136 | await Text(df.to_string(index=False)).send(at_sender=True) 137 | await bind.finish() 138 | 139 | bindInfo = await Bind.get_user_bind(pid=event.get_user_id()) 140 | df = pd.DataFrame(bindInfo) 141 | await Text(df.to_string(index=False)).send(at_sender=True) 142 | await bind.finish() 143 | 144 | 145 | @bind.assign("rebind") 146 | async def rebind(bot: Bot, userInfo: UserInfo): 147 | uid = userInfo.uid 148 | origin_uid = (await Bind.get(uid=uid, platform=bot.type)).origin_uid 149 | if origin_uid is None: 150 | await Text("您还未绑定过其他账号").finish(at_sender=True) 151 | await Bind.cancel(origin_uid, uid, platform=bot.type) 152 | await Text("已取消绑定").send(at_sender=True) 153 | await bind.finish() 154 | 155 | 156 | @user.handle() 157 | async def user_(bot: Bot, event: Event, target: Match[At | int]): 158 | if target.available: 159 | if isinstance(target.result, At): 160 | pid = target.result.target 161 | if not await User.check_exists(bot.adapter.get_name(), pid): 162 | await Text("您@的用户还未注册喔").finish(at_sender=True) 163 | target_id = (await User.get_user_by_pid(pid)).uid 164 | target_user = await User.get_user_by_uid(target_id) 165 | else: 166 | target_id = str(target.result) 167 | target_user = await User.get_user_by_uid(target_id) 168 | else: 169 | target_user = await User.get_user_by_event(event) 170 | 171 | bind_info = await Bind.get(uid=target_user.uid, platform=bot.type) 172 | 173 | await Text( 174 | inspect.cleandoc( 175 | f""" 176 | 平台名:{bind_info.platform} 177 | 用户ID:{bind_info.uid} 178 | 平台ID:{bind_info.pid} 179 | 用户名:{target_user.user_name} 180 | 注册时间:{(target_user.register_time).strftime('%Y-%m-%d %H:%M:%S')} 181 | """ 182 | ) 183 | ).finish() 184 | -------------------------------------------------------------------------------- /sora/plugins/user/model.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class UserToken(BaseModel): 5 | uid: str 6 | token: str 7 | 8 | 9 | class TokenManager: 10 | def __init__(self): 11 | self.tokens = {} 12 | 13 | def add_token(self, uid: str, token: str): 14 | self.tokens[uid] = token 15 | 16 | def remove_token(self, uid: str): 17 | if uid in self.tokens: 18 | del self.tokens[uid] 19 | 20 | def update_token(self, uid: str, token: str): 21 | if uid in self.tokens: 22 | self.tokens[uid] = token 23 | 24 | def get_token(self, uid: str): 25 | return self.tokens.get(uid) 26 | 27 | 28 | class ValidationResult: 29 | def __init__(self, is_valid: bool, uid: str | None = None): 30 | self.is_valid = is_valid 31 | self.uid = uid 32 | 33 | 34 | def validate_token(token: str) -> ValidationResult: 35 | for uid, user_token in token_manager.tokens.items(): 36 | if user_token == token: 37 | return ValidationResult(is_valid=True, uid=uid) 38 | return ValidationResult(is_valid=False) 39 | 40 | 41 | token_manager = TokenManager() 42 | -------------------------------------------------------------------------------- /sora/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netsora/SoraBot/fe1d23394157a1570ccc7cf4cbfecee14c53a976/sora/utils/__init__.py -------------------------------------------------------------------------------- /sora/utils/annotated.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from nonebot.params import Depends 4 | 5 | from sora.database import User as _User 6 | 7 | UserInfo = Annotated[_User, Depends(_User.get_user_by_event)] 8 | -------------------------------------------------------------------------------- /sora/utils/api.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | import hashlib 4 | import datetime 5 | 6 | 7 | def md5(text: str) -> str: 8 | """ 9 | md5加密 10 | 11 | :param text: 文本 12 | :return: md5加密后的文本 13 | """ 14 | md5_ = hashlib.md5() 15 | md5_.update(text.encode()) 16 | return md5_.hexdigest() 17 | 18 | 19 | def generate_id(): 20 | """ 21 | 说明: 22 | 生成九位数用户ID 23 | 结构: 24 | 日期+随机数 25 | """ 26 | current_time = datetime.datetime.now().strftime("%Y%m%d") # 获取当前日期 27 | random_number = random.randint(10, 99) # 生成一个随机数 28 | if random_number < 10: 29 | random_number = "0" + str(random_number) 30 | user_id: str = f"{current_time}{random_number}" # 结合日期和随机数生成ID 31 | return user_id 32 | 33 | 34 | def random_hex(length: int, prefix: str | None = None) -> str: 35 | """ 36 | 说明: 37 | 生成指定长度的随机字符串 38 | 参数: 39 | * length: 长度 40 | * prefix: 增加前缀(可选) 41 | """ 42 | result = hex(random.randint(0, 16**length)).replace("0x", "").upper() 43 | if len(result) < length: 44 | result = "0" * (length - len(result)) + result 45 | if prefix is not None: 46 | result = prefix + result[5:] 47 | return result 48 | 49 | 50 | def random_text(length: int, prefix: str | None = None) -> str: 51 | """ 52 | 说明: 53 | 生成指定长度的随机字符串 54 | 参数: 55 | * length: 长度 56 | * prefix: 前缀(可选) 57 | """ 58 | result = "".join(random.sample(string.ascii_lowercase + string.digits, length)) 59 | if prefix is not None: 60 | result = prefix + result[5:] 61 | return result 62 | -------------------------------------------------------------------------------- /sora/utils/files.py: -------------------------------------------------------------------------------- 1 | try: 2 | import ujson as json 3 | except ImportError: 4 | import json 5 | 6 | from pathlib import Path 7 | from ssl import SSLCertVerificationError 8 | 9 | from ruamel import yaml 10 | 11 | from .requests import AsyncHttpx 12 | 13 | 14 | def load_json(path: Path | str, encoding: str = "utf-8"): 15 | """ 16 | 读取本地json文件,返回文件数据。 17 | 18 | :param path: 文件路径 19 | :param encoding: 编码,默认为utf-8 20 | :return: 数据 21 | """ 22 | if isinstance(path, str): 23 | path = Path(path) 24 | if not path.name.endswith(".json"): 25 | path = path.with_suffix(".json") 26 | return json.loads(path.read_text(encoding=encoding)) if path.exists() else {} 27 | 28 | 29 | async def load_json_from_url( 30 | url: str, path: Path | str | None = None, force_refresh: bool = False 31 | ) -> dict: 32 | """ 33 | 从网络url中读取json,当有path参数时,如果path文件不存在,就会从url下载保存到path,如果path文件存在,则直接读取path 34 | 35 | :param url: url 36 | :param path: 本地json文件路径 37 | :param force_refresh: 是否强制重新下载 38 | :return: json字典 39 | """ 40 | if path and Path(path).exists() and not force_refresh: 41 | return load_json(path=path) 42 | try: 43 | resp = await AsyncHttpx.get(url) 44 | except SSLCertVerificationError: 45 | resp = await AsyncHttpx.get(url.replace("https", "http")) 46 | data = resp.json() 47 | if path and not Path(path).exists(): 48 | save_json(data=data, path=path) 49 | return data 50 | 51 | 52 | def save_json(data: dict, path: Path | str | None = None, encoding: str = "utf-8"): 53 | """ 54 | 保存json文件 55 | 56 | :param data: json数据 57 | :param path: 保存路径 58 | :param encoding: 编码 59 | """ 60 | if isinstance(path, str): 61 | path = Path(path) 62 | if path is not None: 63 | path.parent.mkdir(parents=True, exist_ok=True) 64 | path.write_text( 65 | json.dumps(data, ensure_ascii=False, indent=2), encoding=encoding 66 | ) 67 | 68 | 69 | def load_yaml(path: Path | str, encoding: str = "utf-8"): 70 | """ 71 | 读取本地yaml文件,返回字典。 72 | 73 | :param path: 文件路径 74 | :param encoding: 编码,默认为utf-8 75 | :return: 字典 76 | """ 77 | if isinstance(path, str): 78 | path = Path(path) 79 | return ( 80 | yaml.load(path.read_text(encoding=encoding), Loader=yaml.Loader) 81 | if path.exists() 82 | else {} 83 | ) 84 | 85 | 86 | def save_yaml(data: dict, path: Path | str | None = None, encoding: str = "utf-8"): 87 | """ 88 | 保存yaml文件 89 | 90 | :param data: 数据 91 | :param path: 保存路径 92 | :param encoding: 编码 93 | """ 94 | if isinstance(path, str): 95 | path = Path(path) 96 | if path is not None: 97 | path.parent.mkdir(parents=True, exist_ok=True) 98 | with path.open("w", encoding=encoding) as f: 99 | yaml.dump( 100 | data, 101 | f, 102 | indent=2, 103 | Dumper=yaml.RoundTripDumper, 104 | allow_unicode=True, 105 | ) 106 | -------------------------------------------------------------------------------- /sora/utils/helpers.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from nonebot.matcher import Matcher 4 | from nonebot.adapters import Message 5 | from nonebot.params import Depends, EventMessage 6 | 7 | 8 | def extract_image_urls(message: Message) -> list[str]: 9 | """提取消息中的图片链接 10 | 11 | 参数: 12 | message: 消息对象 13 | 14 | 返回: 15 | 图片链接列表 16 | """ 17 | return [ 18 | segment.data["url"] 19 | for segment in message 20 | if (segment.type == "image") and ("url" in segment.data) 21 | ] 22 | 23 | 24 | CHINESE_CANCELLATION_WORDS = {"算", "别", "不", "停", "取消"} 25 | CHINESE_CANCELLATION_REGEX_1 = re.compile(r"^那?[算别不停]\w{0,3}了?吧?$") 26 | CHINESE_CANCELLATION_REGEX_2 = re.compile(r"^那?(?:[给帮]我)?取消了?吧?$") 27 | 28 | 29 | def is_cancellation(message: Message | str) -> bool: 30 | """判断消息是否表示取消 31 | 32 | 参数: 33 | message: 消息对象或消息文本 34 | 35 | 返回: 36 | 是否表示取消的布尔值 37 | """ """""" 38 | text = message.extract_plain_text() if isinstance(message, Message) else message 39 | return any(kw in text for kw in CHINESE_CANCELLATION_WORDS) and bool( 40 | CHINESE_CANCELLATION_REGEX_1.match(text) 41 | or CHINESE_CANCELLATION_REGEX_2.match(text) 42 | ) 43 | 44 | 45 | def HandleCancellation(cancel_prompt: str | None = None) -> bool: 46 | """检查消息是否表示取消`is_cancellation`的依赖注入版本 47 | 48 | 参数: 49 | cancel_prompt: 当消息表示取消时发送给用户的取消消息 50 | """ """""" 51 | 52 | async def dependency(matcher: Matcher, message: Message = EventMessage()) -> bool: 53 | cancelled = is_cancellation(message) 54 | if cancelled and cancel_prompt: 55 | await matcher.finish(cancel_prompt) 56 | return not cancelled 57 | 58 | return Depends(dependency) 59 | -------------------------------------------------------------------------------- /sora/utils/requests.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | from typing import Any, cast 4 | from asyncio.exceptions import TimeoutError 5 | 6 | import httpx 7 | import aiofiles 8 | from retrying import retry 9 | from httpx import Response, ConnectTimeout 10 | from nonebot.adapters.telegram import Bot as TGBot 11 | from rich.progress import ( 12 | Progress, 13 | BarColumn, 14 | TextColumn, 15 | DownloadColumn, 16 | TransferSpeedColumn, 17 | ) 18 | 19 | from sora.log import logger 20 | from sora.utils.utils import get_local_proxy 21 | from sora.utils.user_agent import get_user_agent 22 | 23 | 24 | class AsyncHttpx: 25 | proxy = {"http://": get_local_proxy(), "https://": get_local_proxy()} 26 | 27 | @classmethod 28 | @retry(stop_max_attempt_number=3) 29 | async def get( 30 | cls, 31 | url: str, 32 | *, 33 | params: dict[str, Any] | None = None, 34 | headers: dict[str, str] | None = None, 35 | cookies: dict[str, str] | None = None, 36 | verify: bool = True, 37 | use_proxy: bool = True, 38 | proxy: dict[str, str] | None = None, 39 | timeout: int | None = 30, 40 | **kwargs, 41 | ) -> Response: 42 | """ 43 | 说明: 44 | Get 45 | 参数: 46 | :param url: url 47 | :param params: params 48 | :param headers: 请求头 49 | :param cookies: cookies 50 | :param verify: verify 51 | :param use_proxy: 使用默认代理 52 | :param proxy: 指定代理 53 | :param timeout: 超时时间 54 | """ 55 | if not headers: 56 | headers = get_user_agent() 57 | 58 | proxy_ = proxy if proxy else cls.proxy if use_proxy else None 59 | async with httpx.AsyncClient(proxies=proxy_, verify=verify) as client: # type: ignore 60 | return await client.get( 61 | url, 62 | params=params, 63 | headers=headers, 64 | cookies=cookies, 65 | timeout=timeout, 66 | **kwargs, 67 | ) 68 | 69 | @classmethod 70 | async def post( 71 | cls, 72 | url: str, 73 | *, 74 | data: dict[str, str] | None = None, 75 | content: Any = None, 76 | files: Any = None, 77 | verify: bool = True, 78 | use_proxy: bool = True, 79 | proxy: dict[str, str] | None = None, 80 | json: dict[str, Any] | None = None, 81 | params: dict[str, str] | None = None, 82 | headers: dict[str, str] | None = None, 83 | cookies: dict[str, str] | None = None, 84 | timeout: int | None = 30, 85 | **kwargs, 86 | ) -> Response: 87 | """ 88 | 说明: 89 | Post 90 | 参数: 91 | :param url: url 92 | :param data: data 93 | :param content: content 94 | :param files: files 95 | :param use_proxy: 是否默认代理 96 | :param proxy: 指定代理 97 | :param json: json 98 | :param params: params 99 | :param headers: 请求头 100 | :param cookies: cookies 101 | :param timeout: 超时时间 102 | """ 103 | if not headers: 104 | headers = get_user_agent() 105 | proxy_ = proxy if proxy else cls.proxy if use_proxy else None 106 | async with httpx.AsyncClient(proxies=proxy_, verify=verify) as client: # type: ignore 107 | return await client.post( 108 | url, 109 | content=content, 110 | data=data, 111 | files=files, 112 | json=json, 113 | params=params, 114 | headers=headers, 115 | cookies=cookies, 116 | timeout=timeout, 117 | **kwargs, 118 | ) 119 | 120 | @classmethod 121 | async def download_file( 122 | cls, 123 | url: str, 124 | path: str | Path, 125 | *, 126 | params: dict[str, str] | None = None, 127 | verify: bool = True, 128 | use_proxy: bool = True, 129 | proxy: dict[str, str] | None = None, 130 | headers: dict[str, str] | None = None, 131 | cookies: dict[str, str] | None = None, 132 | timeout: int | None = 30, 133 | stream: bool = False, 134 | **kwargs, 135 | ) -> bool: 136 | """ 137 | 说明: 138 | 下载文件 139 | 参数: 140 | :param url: url 141 | :param path: 存储路径 142 | :param params: params 143 | :param verify: verify 144 | :param use_proxy: 使用代理 145 | :param proxy: 指定代理 146 | :param headers: 请求头 147 | :param cookies: cookies 148 | :param timeout: 超时时间 149 | :param stream: 是否使用流式下载(流式写入+进度条,适用于下载大文件) 150 | """ 151 | if isinstance(path, str): 152 | path = Path(path) 153 | path.parent.mkdir(parents=True, exist_ok=True) 154 | try: 155 | for _ in range(3): 156 | if not stream: 157 | try: 158 | content = ( 159 | await cls.get( 160 | url, 161 | params=params, 162 | headers=headers, 163 | cookies=cookies, 164 | use_proxy=use_proxy, 165 | proxy=proxy, 166 | timeout=timeout, 167 | **kwargs, 168 | ) 169 | ).content 170 | async with aiofiles.open(path, "wb") as wf: 171 | await wf.write(content) 172 | logger.success("请求", f"下载 {url} 成功!Path:{path.absolute()}") 173 | return True 174 | except (TimeoutError, ConnectTimeout): 175 | pass 176 | else: 177 | if not headers: 178 | headers = get_user_agent() 179 | proxy_ = proxy if proxy else cls.proxy if use_proxy else None 180 | try: 181 | async with httpx.AsyncClient(proxies=proxy_, verify=verify) as client: # type: ignore 182 | async with client.stream( 183 | "GET", 184 | url, 185 | params=params, 186 | headers=headers, 187 | cookies=cookies, 188 | timeout=timeout, 189 | **kwargs, 190 | ) as response: 191 | logger.info( 192 | "请求", 193 | f"开始下载 {path.name} 到 Path: {path.absolute()}", 194 | ) 195 | async with aiofiles.open(path, "wb") as wf: 196 | total = int(response.headers["Content-Length"]) 197 | with Progress( 198 | TextColumn(path.name), 199 | "[progress.percentage]{task.percentage:>3.0f}%", 200 | BarColumn(bar_width=None), 201 | DownloadColumn(), 202 | TransferSpeedColumn(), 203 | ) as progress: 204 | download_task = progress.add_task( 205 | "Download", total=total 206 | ) 207 | async for chunk in response.aiter_bytes(): 208 | await wf.write(chunk) 209 | await wf.flush() 210 | progress.update( 211 | download_task, 212 | completed=response.num_bytes_downloaded, 213 | ) 214 | logger.success( 215 | "请求", 216 | f"下载 {url} 成功!Path:{path.absolute()}", 217 | ) 218 | return True 219 | except (TimeoutError, ConnectTimeout): 220 | pass 221 | else: 222 | logger.error("请求", f"下载 {url} 下载超时!Path:{path.absolute()}") 223 | except Exception as e: 224 | logger.error("请求", f"下载 {url} 未知错误 {type(e)}:{e} | Path:{path.absolute()}") 225 | return False 226 | 227 | @classmethod 228 | async def gather_download_file( 229 | cls, 230 | url_list: list[str], 231 | path_list: list[str | Path], 232 | *, 233 | limit_async_number: int | None = None, 234 | params: dict[str, str] | None = None, 235 | use_proxy: bool = True, 236 | proxy: dict[str, str] | None = None, 237 | headers: dict[str, str] | None = None, 238 | cookies: dict[str, str] | None = None, 239 | timeout: int | None = 30, 240 | **kwargs, 241 | ) -> list[bool]: 242 | """ 243 | 说明: 244 | 分组同时下载文件 245 | 参数: 246 | :param url_list: url列表 247 | :param path_list: 存储路径列表 248 | :param limit_async_number: 限制同时请求数量 249 | :param params: params 250 | :param use_proxy: 使用代理 251 | :param proxy: 指定代理 252 | :param headers: 请求头 253 | :param cookies: cookies 254 | :param timeout: 超时时间 255 | """ 256 | if n := len(url_list) != len(path_list): 257 | raise UrlPathNumberNotEqual( 258 | f"Url数量与Path数量不对等,Url:{len(url_list)},Path:{len(path_list)}" 259 | ) 260 | if limit_async_number and n > limit_async_number: 261 | m = float(n) / limit_async_number 262 | x = 0 263 | j = limit_async_number 264 | _split_url_list = [] 265 | _split_path_list = [] 266 | for _ in range(int(m)): 267 | _split_url_list.append(url_list[x:j]) 268 | _split_path_list.append(path_list[x:j]) 269 | x += limit_async_number 270 | j += limit_async_number 271 | if int(m) < m: 272 | _split_url_list.append(url_list[j:]) 273 | _split_path_list.append(path_list[j:]) 274 | else: 275 | _split_url_list = [url_list] 276 | _split_path_list = [path_list] 277 | tasks = [] 278 | result_ = [] 279 | for x, y in zip(_split_url_list, _split_path_list): 280 | for url, path in zip(x, y): 281 | tasks.append( 282 | asyncio.create_task( 283 | cls.download_file( 284 | url, 285 | path, 286 | params=params, 287 | headers=headers, 288 | cookies=cookies, 289 | use_proxy=use_proxy, 290 | timeout=timeout, 291 | proxy=proxy, 292 | **kwargs, 293 | ) 294 | ) 295 | ) 296 | _x = await asyncio.gather(*tasks) # type: ignore 297 | result_ = result_ + list(_x) 298 | tasks.clear() 299 | return result_ 300 | 301 | @classmethod 302 | async def download_telegram_file( 303 | cls, 304 | url: str, 305 | path: str | Path, 306 | bot: TGBot, 307 | ) -> bool: 308 | res = await bot.get_file(file_id=url) 309 | file_path = cast(str, res.file_path) 310 | 311 | turl = f"{bot.bot_config.api_server}file/bot{bot.bot_config.token}/{file_path}" 312 | return await cls.download_file(turl, path) 313 | 314 | 315 | class UrlPathNumberNotEqual(Exception): 316 | pass 317 | 318 | 319 | class BrowserIsNone(Exception): 320 | pass 321 | -------------------------------------------------------------------------------- /sora/utils/scheduler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pydantic import Field, BaseModel 4 | from apscheduler.schedulers.asyncio import AsyncIOScheduler 5 | 6 | from sora import get_driver 7 | from sora.log import LoguruHandler, logger 8 | 9 | 10 | class Config(BaseModel): 11 | apscheduler_autostart: bool = True 12 | apscheduler_log_level: int = 30 13 | apscheduler_config: dict = Field( 14 | default_factory=lambda: {"apscheduler.timezone": "Asia/Shanghai"} 15 | ) 16 | 17 | class Config: 18 | extra = "ignore" 19 | 20 | 21 | driver = get_driver() 22 | global_config = driver.config 23 | plugin_config = Config(**global_config.dict()) 24 | 25 | scheduler = AsyncIOScheduler() 26 | scheduler.configure(plugin_config.apscheduler_config) 27 | 28 | 29 | async def _start_scheduler(): 30 | if not scheduler.running: 31 | scheduler.start() 32 | logger.success("⏱️ Scheduler Started") 33 | 34 | 35 | async def _shutdown_scheduler(): 36 | if scheduler.running: 37 | scheduler.shutdown() 38 | logger.opt(colors=True).info("⏱️ Scheduler Shutdown") 39 | 40 | 41 | if plugin_config.apscheduler_autostart: 42 | driver.on_startup(_start_scheduler) 43 | driver.on_shutdown(_shutdown_scheduler) 44 | 45 | aps_logger = logging.getLogger("apscheduler") 46 | aps_logger.setLevel(plugin_config.apscheduler_log_level) 47 | aps_logger.handlers.clear() 48 | aps_logger.addHandler(LoguruHandler()) 49 | -------------------------------------------------------------------------------- /sora/utils/update.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | 4 | from git.repo import Repo 5 | from nonebot.utils import run_sync 6 | from git.exc import GitCommandError, InvalidGitRepositoryError 7 | 8 | from sora.log import logger 9 | from sora.config import bot_config 10 | from sora.version import __version__ 11 | 12 | from .requests import AsyncHttpx 13 | 14 | REPO_COMMITS_URL = "https://api.github.com/repos/netsora/SoraBot/commits" 15 | REPO_RELEASE_URL = "https://api.github.com/repos/netsora/SoraBot/releases" 16 | 17 | 18 | @run_sync 19 | def update(): 20 | try: 21 | repo = Repo(Path().absolute()) 22 | except InvalidGitRepositoryError: 23 | return "没有发现git仓库,无法通过git更新,请手动下载最新版本的文件进行替换。" 24 | logger.info("更新", "开始执行git pull更新操作") 25 | origin = repo.remotes.origin 26 | try: 27 | origin.pull() 28 | msg = f"""更新完成,版本:{__version__}\n可使用命令 [@bot /重启] 重启{bot_config.nickname}""" 29 | except GitCommandError as e: 30 | if "timeout" in e.stderr or "unable to access" in e.stderr: 31 | msg = "更新失败,连接git仓库超时,请重试或修改源为代理源后再重试。" 32 | elif "Your local changes" in e.stderr: 33 | pyproject_file = Path().parent / "pyproject.toml" 34 | pyproject_raw_content = pyproject_file.read_text(encoding="utf-8") 35 | if raw_plugins_load := re.search( 36 | r"^plugins = \[.+]$", pyproject_raw_content, flags=re.M 37 | ): 38 | pyproject_new_content = pyproject_raw_content.replace( 39 | raw_plugins_load.group(), "plugins = []" 40 | ) 41 | logger.info("更新", f"检测到已安装插件:{raw_plugins_load.group()},暂时重置") 42 | else: 43 | pyproject_new_content = pyproject_raw_content 44 | pyproject_file.write_text(pyproject_new_content, encoding="utf-8") 45 | try: 46 | origin.pull() 47 | msg = f"""更新完成,版本:{__version__}\n可使用命令 [@bot /重启] 重启{bot_config.nickname}""" 48 | except GitCommandError as e: 49 | if "timeout" in e.stderr or "unable to access" in e.stderr: 50 | msg = "更新失败,连接git仓库超时,请重试或修改源为代理源后再重试。" 51 | elif " Your local changes" in e.stderr: 52 | msg = f"更新失败,本地修改过文件导致冲突,请解决冲突后再更新。\n{e.stderr}" 53 | else: 54 | msg = f"更新失败,错误信息:{e.stderr},请尝试手动进行更新" 55 | finally: 56 | if raw_plugins_load: 57 | pyproject_new_content = pyproject_file.read_text(encoding="utf-8") 58 | pyproject_new_content = re.sub( 59 | r"^plugins = \[.*]$", 60 | raw_plugins_load.group(), 61 | pyproject_new_content, 62 | ) 63 | pyproject_new_content = pyproject_new_content.replace( 64 | "plugins = []", raw_plugins_load.group() 65 | ) 66 | pyproject_file.write_text(pyproject_new_content, encoding="utf-8") 67 | logger.info("更新", f"更新结束,还原插件:{raw_plugins_load.group()}") 68 | return msg 69 | else: 70 | msg = f"更新失败,错误信息:{e.stderr},请尝试手动进行更新" 71 | return msg 72 | 73 | 74 | class CheckUpdate: 75 | @staticmethod 76 | async def _get_commits_info() -> dict: 77 | req = await AsyncHttpx.get(REPO_COMMITS_URL) 78 | return req.json() 79 | 80 | @staticmethod 81 | async def _get_release_info() -> dict: 82 | req = await AsyncHttpx.get(REPO_RELEASE_URL) 83 | return req.json() 84 | 85 | @classmethod 86 | async def show_latest_commit_info(cls) -> str: 87 | try: 88 | data = await cls._get_commits_info() 89 | except Exception: 90 | logger.error("更新", "获取最新推送信息失败...") 91 | return "" 92 | 93 | try: 94 | commit_data: dict = data[0] 95 | except Exception: 96 | logger.error("更新", "检查更新失败,频率过高") 97 | return "" 98 | 99 | c_info = commit_data["commit"] 100 | c_msg = c_info["message"] 101 | c_sha = commit_data["sha"][0:5] 102 | c_time = c_info["author"]["date"] 103 | 104 | return f"Latest commit {c_msg} | sha: {c_sha} | time: {c_time}" 105 | 106 | @classmethod 107 | async def show_latest_version(cls) -> tuple: 108 | try: 109 | data = await cls._get_release_info() 110 | except Exception: 111 | logger.error("更新", "获取发布列表失败...") 112 | return "", "" 113 | 114 | try: 115 | release_data: dict = data[0] 116 | except Exception: 117 | logger.error("更新", "检查更新失败,频率过高") 118 | return "", "" 119 | 120 | l_v = release_data["tag_name"] 121 | l_v_t = release_data["published_at"] 122 | return l_v, l_v_t 123 | -------------------------------------------------------------------------------- /sora/utils/user.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | 5 | def generate_password(length=10, chars=string.ascii_letters + string.digits): 6 | """生成用户密码""" 7 | 8 | return "".join([random.choice(chars) for i in range(length)]) 9 | -------------------------------------------------------------------------------- /sora/utils/user_agent.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | user_agent = [ 4 | "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50", # noqa: E501 5 | "Mozilla/5.0 (Windows; U; Windows NT 6.1; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50", 6 | "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:38.0) Gecko/20100101 Firefox/38.0", 7 | "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; InfoPath.3; rv:11.0) like Gecko", # noqa: E501 8 | "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)", 9 | "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0)", 10 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0)", 11 | "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)", 12 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:2.0.1) Gecko/20100101 Firefox/4.0.1", 13 | "Mozilla/5.0 (Windows NT 6.1; rv:2.0.1) Gecko/20100101 Firefox/4.0.1", 14 | "Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8; U; en) Presto/2.8.131 Version/11.11", 15 | "Opera/9.80 (Windows NT 6.1; U; en) Presto/2.8.131 Version/11.11", 16 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_0) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.56 Safari/535.11", # noqa: E501 17 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Maxthon 2.0)", 18 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; TencentTraveler 4.0)", 19 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)", 20 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; The World)", 21 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Trident/4.0; SE 2.X MetaSr 1.0; SE 2.X MetaSr 1.0; .NET CLR 2.0.50727; SE 2.X MetaSr 1.0)", # noqa: E501 22 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; 360SE)", 23 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Avant Browser)", 24 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)", 25 | "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5", # noqa: E501 26 | "Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5", # noqa: E501 27 | "Mozilla/5.0 (iPad; U; CPU OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5", # noqa: E501 28 | "Mozilla/5.0 (Linux; U; Android 2.3.7; en-us; Nexus One Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", # noqa: E501 29 | "MQQBrowser/26 Mozilla/5.0 (Linux; U; Android 2.3.7; zh-cn; MB200 Build/GRJ22; CyanogenMod-7) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", # noqa: E501 30 | "Opera/9.80 (Android 2.3.4; Linux; Opera Mobi/build-1107180945; U; en-GB) Presto/2.8.149 Version/11.10", 31 | "Mozilla/5.0 (Linux; U; Android 3.0; en-us; Xoom Build/HRI39) AppleWebKit/534.13 (KHTML, like Gecko) Version/4.0 Safari/534.13", # noqa: E501 32 | "Mozilla/5.0 (BlackBerry; U; BlackBerry 9800; en) AppleWebKit/534.1+ (KHTML, like Gecko) Version/6.0.0.337 Mobile Safari/534.1+", # noqa: E501 33 | "Mozilla/5.0 (hp-tablet; Linux; hpwOS/3.0.0; U; en-US) AppleWebKit/534.6 (KHTML, like Gecko) wOSBrowser/233.70 Safari/534.6 TouchPad/1.0", # noqa: E501 34 | "Mozilla/5.0 (SymbianOS/9.4; Series60/5.0 NokiaN97-1/20.0.019; Profile/MIDP-2.1 Configuration/CLDC-1.1) AppleWebKit/525 (KHTML, like Gecko) BrowserNG/7.1.18124", # noqa: E501 35 | "Mozilla/5.0 (compatible; MSIE 9.0; Windows Phone OS 7.5; Trident/5.0; IEMobile/9.0; HTC; Titan)", 36 | "UCWEB7.0.2.37/28/999", 37 | "NOKIA5700/ UCWEB7.0.2.37/28/999", 38 | "Openwave/ UCWEB7.0.2.37/28/999", 39 | "Mozilla/4.0 (compatible; MSIE 6.0; ) Opera/UCWEB7.0.2.37/28/999", 40 | # iPhone 6: 41 | "Mozilla/6.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/8.0 Mobile/10A5376e Safari/8536.25", # noqa: E501 42 | ] 43 | 44 | 45 | def get_user_agent(): 46 | return {"User-Agent": random.choice(user_agent)} 47 | -------------------------------------------------------------------------------- /sora/utils/utils.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | try: 4 | import ujson as json 5 | except ImportError: 6 | import json 7 | 8 | from nonebot.internal.adapter import Message 9 | 10 | from sora.log import logger 11 | from sora.config import bot_config 12 | 13 | 14 | def get_message_at(data: str) -> list: 15 | """ 16 | 获取at列表 17 | :param data: event.json() 18 | """ 19 | at_list = [] 20 | data_ = json.loads(data) 21 | try: 22 | for msg in data_["message"]: 23 | if msg["type"] == "at": 24 | at_list.append(int(msg["data"]["qq"])) 25 | return at_list 26 | except Exception: 27 | return [] 28 | 29 | 30 | async def get_message_img(data: str | Message) -> list[str]: 31 | """ 32 | 获取消息中所有的 图片 的链接 33 | 34 | :param data: event.json() 35 | """ 36 | img_list = [] 37 | if isinstance(data, str): 38 | event = json.loads(data) 39 | if data and (message := event.get("message")): 40 | for msg in message: 41 | if msg["type"] == "image": 42 | img_list.append(msg["data"]["url"]) 43 | elif msg["type"] == "attachments": 44 | img_list.append(msg["data"]["url"]) 45 | else: 46 | img_list.append(msg["data"]["url"]) 47 | else: 48 | if data["image"]: 49 | for seg in data["image"]: 50 | img_list.append(seg.data["url"]) 51 | elif data["attachment"]: 52 | for seg in data["attachment"]: 53 | img_list.append(seg.data["url"]) 54 | else: 55 | if photo := data["photo"]: 56 | img_list.append(photo[0].data["file"]) 57 | 58 | return img_list 59 | 60 | 61 | def get_message_face(data: str | Message) -> list[str]: 62 | """ 63 | 获取消息中所有的 face Id 64 | 65 | :param data: event.json() 66 | """ 67 | face_list = [] 68 | if isinstance(data, str): 69 | event = json.loads(data) 70 | if data and (message := event.get("message")): 71 | for msg in message: 72 | if msg["type"] == "face": 73 | face_list.append(msg["data"]["id"]) 74 | else: 75 | for seg in data["face"]: 76 | face_list.append(seg.data["id"]) 77 | return face_list 78 | 79 | 80 | def get_message_img_file(data: str | Message) -> list[str]: 81 | """ 82 | 获取消息中所有的 图片file 83 | 84 | :param data: event.json() 85 | """ 86 | file_list = [] 87 | if isinstance(data, str): 88 | event = json.loads(data) 89 | if data and (message := event.get("message")): 90 | for msg in message: 91 | if msg["type"] == "image": 92 | file_list.append(msg["data"]["file"]) 93 | else: 94 | for seg in data["image"]: 95 | file_list.append(seg.data["file"]) 96 | return file_list 97 | 98 | 99 | def get_message_text(data: str | Message) -> str: 100 | """ 101 | 获取消息中 纯文本 的信息 102 | 103 | :param data: event.json() 104 | """ 105 | result = "" 106 | if isinstance(data, str): 107 | event = json.loads(data) 108 | if data and (message := event.get("message")): 109 | if isinstance(message, str): 110 | return message.strip() 111 | for msg in message: 112 | if msg["type"] == "text": 113 | result += msg["data"]["text"].strip() + " " 114 | return result.strip() 115 | else: 116 | for seg in data["text"]: 117 | result += seg.data["text"] + " " 118 | return result.strip() 119 | 120 | 121 | def get_local_proxy() -> str | None: 122 | """ 123 | 获取 .env* 中设置的代理 124 | """ 125 | return bot_config.proxy_url or None 126 | 127 | 128 | async def get_user_avatar(qq: int) -> bytes | None: 129 | """ 130 | 快捷获取用户头像(仅支持 v11) 131 | :param qq: qq号 132 | """ 133 | url = f"http://q1.qlogo.cn/g?b=qq&nk={qq}&s=160" 134 | async with httpx.AsyncClient() as client: 135 | for _ in range(3): 136 | try: 137 | return (await client.get(url)).content 138 | except TimeoutError: 139 | pass 140 | return None 141 | 142 | 143 | def is_number(s: int | str) -> bool: 144 | """ 145 | 说明: 146 | 检测 s 是否为数字 147 | 参数: 148 | * s: 文本 149 | """ 150 | if isinstance(s, int): 151 | return True 152 | try: 153 | float(s) 154 | return True 155 | except ValueError: 156 | pass 157 | try: 158 | import unicodedata 159 | 160 | unicodedata.numeric(s) 161 | return True 162 | except (TypeError, ValueError): 163 | pass 164 | return False 165 | 166 | 167 | async def translate(content: str, type: str = "AUTO") -> str: 168 | """ 169 | 说明: 170 | 翻译(有道) 171 | 参数: 172 | * type: 类型 173 | * content: 内容 174 | 175 | type的类型有: 176 | * ZH_CN2EN 中文 » 英语 177 | * ZH_CN2JA 中文 » 日语 178 | * ZH_CN2KR 中文 » 韩语 179 | * ZH_CN2FR 中文 » 法语 180 | * ZH_CN2RU 中文 » 俄语 181 | * ZH_CN2SP 中文 » 西语 182 | * EN2ZH_CN 英语 » 中文 183 | * JA2ZH_CN 日语 » 中文 184 | * KR2ZH_CN 韩语 » 中文 185 | * FR2ZH_CN 法语 » 中文 186 | * RU2ZH_CN 俄语 » 中文 187 | * SP2ZH_CN 西语 » 中文 188 | """ 189 | url = f"https://fanyi.youdao.com/translate?&doctype=json&type={type}&i={content}" 190 | logger.info("翻译", f"正在翻译{content}") 191 | async with httpx.AsyncClient() as client: 192 | try: 193 | json_data = (await client.get(url)).json() 194 | result = json_data["translateResult"][0][0]["tgt"] 195 | logger.success("翻译", f"翻译成功:{result}") 196 | except TimeoutError: 197 | result = content 198 | logger.error("翻译", "翻译失败,已返回原文") 199 | return result 200 | -------------------------------------------------------------------------------- /sora/version.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import metadata 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class Metadata(BaseModel): 7 | name: str 8 | version: str 9 | summary: str 10 | 11 | class Config: 12 | extra = "allow" 13 | 14 | 15 | __metadata__ = Metadata(**metadata("sorabot").json) # type: ignore 16 | 17 | __version__ = __metadata__.version 18 | -------------------------------------------------------------------------------- /tests/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | plugins = 3 | coverage_conditional_plugin 4 | 5 | [report] 6 | exclude_lines = 7 | pragma: no cover 8 | def __repr__ 9 | def __str__ 10 | @(typing\.)?overload 11 | if (typing\.)?TYPE_CHECKING( is True)?: 12 | @(abc\.)?abstractmethod 13 | raise NotImplementedError 14 | \.\.\. 15 | pass 16 | if __name__ == .__main__.: 17 | 18 | [coverage_conditional_plugin] 19 | rules = 20 | "sys_platform != 'win32'": py-win32 21 | "sys_platform != 'linux'": py-linux 22 | "sys_platform != 'darwin'": py-darwin 23 | "sys_version_info < (3, 9)": py-gte-39 24 | "sys_version_info < (3, 11)": py-gte-311 25 | "sys_version_info >= (3, 11)": py-lt-311 26 | --------------------------------------------------------------------------------