├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ └── feature_request.yml ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── codecov.yml │ ├── release-draft.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── CHANGELOG.md ├── LICENSE ├── README.md ├── nonebot_plugin_wordcloud ├── SourceHanSans.otf ├── __init__.py ├── compat.py ├── config.py ├── data_source.py ├── migrations │ ├── 557fef3a156f_init_db.py │ ├── __init__.py │ └── ade8cdca5470_migrate_datastore_data.py ├── model.py ├── schedule.py └── utils.py ├── pyproject.toml ├── tests ├── __init__.py ├── conftest.py ├── mask.png ├── test_colormap.png ├── test_colormap.py ├── test_config.py ├── test_masked.png ├── test_masked.py ├── test_pre_process.py ├── test_schedule.py ├── test_stopwords.py ├── test_timezone.py ├── test_userdict.py ├── test_wordcloud.png ├── test_wordcloud.py └── utils.py └── uv.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 2 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.py] 13 | indent_size = 4 14 | 15 | [*.md] 16 | max_line_length = off 17 | insert_final_newline = false 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug 反馈 2 | title: "Bug: 出现异常" 3 | description: 提交 Bug 反馈以帮助我们改进代码 4 | labels: ["bug"] 5 | body: 6 | - type: input 7 | id: env-wordcloud 8 | attributes: 9 | label: wordcloud 版本 10 | description: 填写 nonebot-plugin-wordcloud 版本 11 | placeholder: e.g. 0.5.2 12 | validations: 13 | required: true 14 | 15 | - type: input 16 | id: env-chatrecorder 17 | attributes: 18 | label: chatrecorder 版本 19 | description: 填写 nonebot-plugin-chatrecorder 版本 20 | placeholder: e.g. 0.4.2 21 | validations: 22 | required: true 23 | 24 | - type: input 25 | id: env-saa 26 | attributes: 27 | label: saa 版本 28 | description: 填写 nonebot-plugin-send-anything-anywhere 版本 29 | placeholder: e.g. 0.3.2 30 | validations: 31 | required: true 32 | 33 | - type: input 34 | id: env-alconna 35 | attributes: 36 | label: alconna 版本 37 | description: 填写 nonebot-plugin-alconna 版本 38 | placeholder: e.g. 0.19.0 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | id: describe 44 | attributes: 45 | label: 描述问题 46 | description: 清晰简洁地说明问题是什么 47 | validations: 48 | required: true 49 | 50 | - type: textarea 51 | id: reproduction 52 | attributes: 53 | label: 复现步骤 54 | description: 提供能复现此问题的详细操作步骤 55 | placeholder: | 56 | 1. 首先…… 57 | 2. 然后…… 58 | 3. 发生…… 59 | validations: 60 | required: true 61 | 62 | - type: textarea 63 | id: expected 64 | attributes: 65 | label: 期望的结果 66 | description: 清晰简洁地描述你期望发生的事情 67 | 68 | - type: textarea 69 | id: logs 70 | attributes: 71 | label: 截图或日志 72 | description: 提供有助于诊断问题的任何日志和截图 73 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 讨论区 4 | url: https://github.com/he0119/nonebot-plugin-wordcloud/discussions 5 | about: 使用中若遇到问题,请先在这里求助。 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 功能建议 2 | title: "Feature: 功能描述" 3 | description: 提出关于项目新功能的想法 4 | labels: ["enhancement"] 5 | body: 6 | - type: textarea 7 | id: problem 8 | attributes: 9 | label: 希望能解决的问题 10 | description: 在使用中遇到什么问题而需要新的功能? 11 | validations: 12 | required: true 13 | 14 | - type: textarea 15 | id: feature 16 | attributes: 17 | label: 描述所需要的功能 18 | description: 请说明需要的功能或解决方法 19 | validations: 20 | required: true 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | groups: 8 | production-dependencies: 9 | dependency-type: "production" 10 | update-types: 11 | - "patch" 12 | - "minor" 13 | development-dependencies: 14 | dependency-type: "development" 15 | update-types: 16 | - "patch" 17 | - "minor" 18 | - "major" 19 | - package-ecosystem: "github-actions" 20 | directory: "/" 21 | schedule: 22 | interval: "daily" 23 | groups: 24 | actions-update: 25 | patterns: 26 | - "*" 27 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: $CHANGES 2 | name-template: "v$RESOLVED_VERSION" 3 | tag-template: "v$RESOLVED_VERSION" 4 | exclude-labels: 5 | - "dependencies" 6 | - "skip-changelog" 7 | autolabeler: 8 | - label: "bug" 9 | branch: 10 | - '/fix\/.+/' 11 | - label: "change" 12 | branch: 13 | - '/change\/.+/' 14 | - label: "enhancement" 15 | branch: 16 | - '/feature\/.+/' 17 | - '/feat\/.+/' 18 | - '/improve\/.+/' 19 | - label: "ci" 20 | files: 21 | - .github/**/* 22 | - label: "breaking-change" 23 | title: 24 | - "/.+!:.+/" 25 | categories: 26 | - title: 💥 破坏性变更 27 | labels: 28 | - breaking-change 29 | - title: 🚀 新功能 30 | labels: 31 | - enhancement 32 | - title: 🐛 Bug 修复 33 | labels: 34 | - bug 35 | - title: 💫 杂项 36 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 37 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 38 | version-resolver: 39 | major: 40 | labels: 41 | - "major" 42 | minor: 43 | labels: 44 | - "minor" 45 | patch: 46 | labels: 47 | - "patch" 48 | default: patch 49 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: Code Coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | test: 15 | name: Test 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, windows-latest, macos-latest] 20 | python-version: ["3.9", "3.10", "3.11", "3.12"] 21 | db: ["sqlite+aiosqlite://"] 22 | db-pool: ["StaticPool"] 23 | include: 24 | - os: ubuntu-latest 25 | python-version: "3.10" 26 | db: postgresql+asyncpg://postgres:postgres@localhost:5432/postgres 27 | db-pool: NullPool 28 | 29 | - os: ubuntu-latest 30 | python-version: "3.10" 31 | db: mysql+aiomysql://mysql:mysql@localhost:3306/mymysql 32 | db-pool: NullPool 33 | fail-fast: false 34 | env: 35 | OS: ${{ matrix.os }} 36 | PYTHON_VERSION: ${{ matrix.python-version }} 37 | SQLALCHEMY_DATABASE_URL: ${{ matrix.db }} 38 | SQLALCHEMY_POOL_CLASS: ${{ matrix.db-pool }} 39 | TZ: "Asia/Shanghai" 40 | services: 41 | postgresql: 42 | image: ${{ startsWith(matrix.db, 'postgresql') && 'postgres' || '' }} 43 | env: 44 | POSTGRES_USER: postgres 45 | POSTGRES_PASSWORD: postgres 46 | POSTGRES_DB: postgres 47 | ports: 48 | - 5432:5432 49 | mysql: 50 | image: ${{ startsWith(matrix.db, 'mysql') && 'mysql' || '' }} 51 | env: 52 | MYSQL_ROOT_PASSWORD: mysql 53 | MYSQL_USER: mysql 54 | MYSQL_PASSWORD: mysql 55 | MYSQL_DATABASE: mymysql 56 | ports: 57 | - 3306:3306 58 | steps: 59 | - name: Checkout 60 | uses: actions/checkout@v4 61 | 62 | - name: Set timezone 63 | uses: szenius/set-timezone@1f9716b0f7120e344f0c62bb7b1ee98819aefd42 64 | with: 65 | timezoneLinux: "Asia/Shanghai" 66 | timezoneMacos: "Asia/Shanghai" 67 | timezoneWindows: "China Standard Time" 68 | 69 | - name: Setup uv 70 | uses: astral-sh/setup-uv@v6 71 | with: 72 | enable-cache: true 73 | python-version: ${{ matrix.python-version }} 74 | 75 | - name: Run tests 76 | shell: bash 77 | run: | 78 | sed -ie "s#sqlite+aiosqlite://#${SQLALCHEMY_DATABASE_URL}#g" tests/conftest.py 79 | sed -ie "s#StaticPool#${SQLALCHEMY_POOL_CLASS}#g" tests/conftest.py 80 | if [[ "${PYTHON_VERSION}" == "3.12" ]]; then 81 | uv run poe test:sysmon 82 | elif [[ "${SQLALCHEMY_DATABASE_URL}" != sqlite* ]]; then 83 | uv run poe test:single 84 | else 85 | uv run poe test:gevent 86 | fi 87 | 88 | - name: Upload test results to Codecov 89 | if: ${{ !cancelled() }} 90 | uses: codecov/test-results-action@v1 91 | with: 92 | token: ${{ secrets.CODECOV_TOKEN }} 93 | env_vars: OS,PYTHON_VERSION,SQLALCHEMY_DATABASE_URL,SQLALCHEMY_POOL_CLASS 94 | 95 | - name: Upload coverage to Codecov 96 | uses: codecov/codecov-action@v5 97 | with: 98 | token: ${{ secrets.CODECOV_TOKEN }} 99 | env_vars: OS,PYTHON_VERSION,SQLALCHEMY_DATABASE_URL,SQLALCHEMY_POOL_CLASS 100 | 101 | check: 102 | if: always() 103 | needs: test 104 | runs-on: ubuntu-latest 105 | steps: 106 | - name: Decide whether the needed jobs succeeded or failed 107 | uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe 108 | with: 109 | jobs: ${{ toJSON(needs) }} 110 | -------------------------------------------------------------------------------- /.github/workflows/release-draft.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, reopened, synchronize] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | update_release_draft: 15 | name: Update Release Draft 16 | runs-on: ubuntu-latest 17 | permissions: 18 | # write permission is required to create a github release 19 | contents: write 20 | # write permission is required for autolabeler 21 | # otherwise, read permission is required at least 22 | pull-requests: write 23 | steps: 24 | - uses: release-drafter/release-drafter@v6 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | environment: release 13 | permissions: 14 | id-token: write 15 | contents: write 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Install the latest version of uv 21 | uses: astral-sh/setup-uv@v6 22 | with: 23 | enable-cache: true 24 | 25 | - name: Get Version 26 | id: version 27 | run: | 28 | echo "VERSION=$(uvx --from=toml-cli toml get --toml-path=pyproject.toml project.version)" >> $GITHUB_OUTPUT 29 | echo "TAG_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT 30 | echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 31 | 32 | - name: Check Version 33 | if: steps.version.outputs.VERSION != steps.version.outputs.TAG_VERSION 34 | run: exit 1 35 | 36 | - name: Build 37 | run: uv build 38 | 39 | - name: Publish a Python distribution to PyPI 40 | uses: pypa/gh-action-pypi-publish@release/v1 41 | 42 | - name: Upload Release Asset 43 | run: gh release upload --clobber ${{ steps.version.outputs.TAG_NAME }} dist/*.tar.gz dist/*.whl 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | pytestdebug.log 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | doc/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | 137 | # pytype static type analyzer 138 | .pytype/ 139 | 140 | # End of https://www.toptal.com/developers/gitignore/api/python 141 | 142 | # VSCode 143 | .vscode/ 144 | 145 | data/ 146 | 147 | junit.xml 148 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_commit_msg: "style: auto fix by pre-commit hooks" 3 | autofix_prs: true 4 | autoupdate_branch: main 5 | autoupdate_schedule: quarterly 6 | autoupdate_commit_msg: "chore: auto update by pre-commit hooks" 7 | repos: 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.8.2 10 | hooks: 11 | - id: ruff 12 | args: [--fix, --exit-non-zero-on-fix] 13 | stages: [pre-commit] 14 | - id: ruff-format 15 | 16 | - repo: https://github.com/pre-commit/mirrors-prettier 17 | rev: v4.0.0-alpha.8 18 | hooks: 19 | - id: prettier 20 | types_or: [javascript, jsx, ts, tsx, markdown, yaml, json] 21 | stages: [pre-commit] 22 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.9.20 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Added 11 | 12 | - 添加是否发送词云图片时回复消息的配置项 13 | 14 | ## [0.9.0] - 2025-01-14 15 | 16 | ### Added 17 | 18 | - 适配 chatrecorder 0.7.0 19 | 20 | ## [0.8.1] - 2024-12-24 21 | 22 | ### Added 23 | 24 | - 给快捷命令添加人类可读描述 25 | 26 | ### Fixed 27 | 28 | - 限制 chatrecorder 的版本并添加 session 依赖 29 | 30 | ## [0.8.0] - 2024-08-15 31 | 32 | ### Added 33 | 34 | - 添加命令的帮助信息 35 | 36 | ### Changed 37 | 38 | - 事件响应器现在将阻断事件的传播 39 | 40 | ## [0.7.3] - 2024-07-13 41 | 42 | ### Fixed 43 | 44 | - 适配 Alconna 0.49.0 45 | 46 | ## [0.7.2] - 2024-04-29 47 | 48 | ### Fixed 49 | 50 | - 修复某个群组定时发送词云失败时影响其他群组的问题 51 | 52 | ## [0.7.1] - 2024-03-24 53 | 54 | ### Fixed 55 | 56 | - 修复 shortcut 匹配问题 57 | 58 | ## [0.7.0] - 2024-03-01 59 | 60 | ### Added 61 | 62 | - 适配 Pydantic V2 63 | 64 | ## [0.6.1] - 2023-10-24 65 | 66 | ### Added 67 | 68 | - 升级 orm 版本 69 | 70 | ## [0.6.0] - 2023-10-18 71 | 72 | ### Changed 73 | 74 | - 直接使用 cesaa 中的函数替代 chatrecorder 75 | - 使用 alconna 新提供的依赖注入 76 | - 迁移至 nb orm 77 | - 如果安装有 datastore 则从中迁移数据 78 | 79 | ## [0.5.2] - 2023-08-26 80 | 81 | ### Fixed 82 | 83 | - 提高 NoneBot 版本限制修复报错问题 84 | 85 | ## [0.5.1] - 2023-08-26 86 | 87 | ### Fixed 88 | 89 | - 修复默认不遵守 nb 的 command_start 配置的问题 90 | 91 | ## [0.5.0] - 2023-08-24 92 | 93 | ### Added 94 | 95 | - 支持多适配器 96 | - 支持随机选择色彩映射表的值 97 | 98 | ### Changed 99 | 100 | - 使用 Alconna 快捷指令简化代码 101 | 102 | ### Fixed 103 | 104 | - 修复定时发送的词云没有排除指定用户的问题 105 | 106 | ## [0.4.9] - 2023-06-27 107 | 108 | ### Added 109 | 110 | - 适配最新插件元数据 111 | 112 | ## [0.4.8] - 2023-03-19 113 | 114 | ### Added 115 | 116 | - 支持直接向词云传递参数 117 | - 支持排除特定用户 118 | 119 | ### Fixed 120 | 121 | - 修复运行迁移脚本出错的问题 122 | 123 | ## [0.4.7] - 2023-03-12 124 | 125 | ### Fixed 126 | 127 | - 修复默认词云形状无效的问题 128 | 129 | ## [0.4.6] - 2023-03-07 130 | 131 | ### Fixed 132 | 133 | - 修复 UniqueConstraint 失效的问题 134 | - 修复无法在 PostgreSQL 与 MySQL 上使用的问题 135 | 136 | ## [0.4.5] - 2023-03-04 137 | 138 | ### Fixed 139 | 140 | - 修复 OneBot 12 协议下下载图片出错的问题 141 | 142 | ## [0.4.4] - 2023-02-02 143 | 144 | ### Fixed 145 | 146 | - 通过每次关闭线程池修复内存泄漏问题 147 | 148 | ## [0.4.3] - 2023-02-01 149 | 150 | ### Fixed 151 | 152 | - 修复 GROUP BY 在 PostgreSQL 上的用法错误 153 | - 设置/删除词云默认形状的权限调整为仅超级用户 154 | 155 | ## [0.4.2] - 2023-01-23 156 | 157 | ### Fixed 158 | 159 | - 修复 OneBot 适配器依赖问题 160 | 161 | ## [0.4.1] - 2023-01-23 162 | 163 | ### Fixed 164 | 165 | - 修复迁移脚本没有给之前的数据设置默认值的问题 166 | 167 | ## [0.4.0] - 2023-01-22 168 | 169 | ### Added 170 | 171 | - 支持 OneBot 12 适配器 172 | - 添加 `上周词云` 和 `上月词云` 173 | 174 | ## [0.3.1] - 2022-12-27 175 | 176 | ### Added 177 | 178 | - 支持定时发送每日词云 179 | - 支持每个群单独设置词云形状 180 | 181 | ### Fixed 182 | 183 | - 修复发送 `/词云` 后帮助信息为空的问题 184 | 185 | ## [0.3.0] - 2022-10-06 186 | 187 | ### Added 188 | 189 | - 支持自定义词云形状 190 | 191 | ### Changed 192 | 193 | - 仅支持 NoneBot2 RC1 及以上版本 194 | 195 | ## [0.2.4] - 2022-07-07 196 | 197 | ### Fixed 198 | 199 | - 修复无法正确获取到消息的问题 200 | 201 | ## [0.2.3] - 2022-07-03 202 | 203 | ### Added 204 | 205 | - 适配插件元数据 206 | 207 | ### Changed 208 | 209 | - 调整 `font_path` 默认配置设置方法 210 | 211 | ### Fixed 212 | 213 | - 修复网址预处理无法处理掉微博国际版分享地址的问题 214 | 215 | ## [0.2.2] - 2022-06-10 216 | 217 | ### Added 218 | 219 | - 添加字体色彩映射表配置项 220 | 221 | ## [0.2.1] - 2022-05-25 222 | 223 | ### Changed 224 | 225 | - 将函数放在执行器中运行防止生成词云时阻塞事件循环 226 | 227 | ## [0.2.0] - 2022-05-25 228 | 229 | ### Changed 230 | 231 | - 直接使用基于 TF-IDF 算法的关键词抽取 232 | - 不需要限制 tzdata 的版本 233 | 234 | ### Removed 235 | 236 | - 删除 Python 3.7 的支持 237 | 238 | ## [0.1.2] - 2022-05-21 239 | 240 | ### Changed 241 | 242 | - 调整插件加载方式,并删除 numpy 依赖 243 | 244 | ## [0.1.1] - 2022-04-16 245 | 246 | ### Changed 247 | 248 | - 仅当消息为词云两字时才发送帮助 249 | 250 | ## [0.1.0] - 2022-02-26 251 | 252 | ### Changed 253 | 254 | - 历史词云支持直接输入日期和时间,不局限于日期 255 | 256 | ### Removed 257 | 258 | - 移除迁移词云命令 259 | 260 | ## [0.0.8] - 2022-02-23 261 | 262 | ### Added 263 | 264 | - 支持查询本周与本月词云 265 | 266 | ### Changed 267 | 268 | - 我的词云系列命令,回复消息时将会@用户 269 | 270 | ## [0.0.7] - 2022-02-23 271 | 272 | ### Added 273 | 274 | - 支持查询我的词云 275 | - 支持查询年度词云 276 | 277 | ## [0.0.6] - 2022-02-04 278 | 279 | ### Added 280 | 281 | - 支持设置时区 282 | 283 | ### Fixed 284 | 285 | - 修复 emoji 去除不全的问题 286 | - 修复日期错误时报错的问题 287 | 288 | ## [0.0.5] - 2022-02-02 289 | 290 | ### Added 291 | 292 | - 支持添加用户词典 293 | - 新增历史词云功能 294 | - 新增回复帮助信息 295 | 296 | ### Changed 297 | 298 | - 结巴分词使用精确模式替代之前的全模式 299 | - 使用 nonebot-plugin-chatrecorder 作为数据源 300 | 301 | ## [0.0.4] - 2022-01-30 302 | 303 | ### Changed 304 | 305 | - 去除网址与特殊字符(不可见字符与 emoji) 306 | 307 | ## [0.0.3] - 2022-01-30 308 | 309 | ### Changed 310 | 311 | - 使用思源黑体 312 | - 更新停用词表 313 | 314 | ## [0.0.2] - 2022-01-29 315 | 316 | ### Fixed 317 | 318 | - 修复 Python 3.9 以下版本无法运行的问题 319 | 320 | ## [0.0.1] - 2022-01-29 321 | 322 | ### Added 323 | 324 | - 可以使用的版本。 325 | 326 | [Unreleased]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.9.0...HEAD 327 | [0.9.0]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.8.1...v0.9.0 328 | [0.8.1]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.8.0...v0.8.1 329 | [0.8.0]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.7.3...v0.8.0 330 | [0.7.3]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.7.2...v0.7.3 331 | [0.7.2]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.7.1...v0.7.2 332 | [0.7.1]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.7.0...v0.7.1 333 | [0.7.0]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.6.1...v0.7.0 334 | [0.6.1]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.6.0...v0.6.1 335 | [0.6.0]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.5.2...v0.6.0 336 | [0.5.2]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.5.1...v0.5.2 337 | [0.5.1]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.5.0...v0.5.1 338 | [0.5.0]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.4.9...v0.5.0 339 | [0.4.9]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.4.8...v0.4.9 340 | [0.4.8]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.4.7...v0.4.8 341 | [0.4.7]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.4.6...v0.4.7 342 | [0.4.6]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.4.5...v0.4.6 343 | [0.4.5]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.4.4...v0.4.5 344 | [0.4.4]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.4.3...v0.4.4 345 | [0.4.3]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.4.2...v0.4.3 346 | [0.4.2]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.4.1...v0.4.2 347 | [0.4.1]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.4.0...v0.4.1 348 | [0.4.0]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.3.1...v0.4.0 349 | [0.3.1]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.3.0...v0.3.1 350 | [0.3.0]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.2.4...v0.3.0 351 | [0.2.4]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.2.3...v0.2.4 352 | [0.2.3]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.2.2...v0.2.3 353 | [0.2.2]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.2.1...v0.2.2 354 | [0.2.1]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.2.0...v0.2.1 355 | [0.2.0]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.1.2...v0.2.0 356 | [0.1.2]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.1.1...v0.1.2 357 | [0.1.1]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.1.0...v0.1.1 358 | [0.1.0]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.0.8...v0.1.0 359 | [0.0.8]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.0.7...v0.0.8 360 | [0.0.7]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.0.6...v0.0.7 361 | [0.0.6]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.0.5...v0.0.6 362 | [0.0.5]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.0.4...v0.0.5 363 | [0.0.4]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.0.3...v0.0.4 364 | [0.0.3]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.0.2...v0.0.3 365 | [0.0.2]: https://github.com/he0119/nonebot-plugin-wordcloud/compare/v0.0.1...v0.0.2 366 | [0.0.1]: https://github.com/he0119/nonebot-plugin-wordcloud/releases/tag/v0.0.1 367 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 hemengyang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | nonebot 5 |

6 | 7 |
8 | 9 | # NoneBot Plugin WordCloud 10 | 11 | _✨ NoneBot 词云插件 ✨_ 12 | 13 |
14 | 15 |

16 | 17 | license 18 | 19 | 20 | pypi 21 | 22 | python 23 | 24 | codecov/>
 25 |   </a>
 26 |   <a href= 27 | QQ Chat Group 28 | 29 |

30 | 31 | ## 安装 32 | 33 | ```shell 34 | nb plugin install nonebot-plugin-wordcloud 35 | ``` 36 | 37 | > **Note** 38 | > 39 | > 如需通过命令设置词云形状,则需使用支持 http 请求的驱动器,如 httpx 和 aiohttp。 40 | > 41 | > 请参考 [配置驱动器](https://nonebot.dev/docs/advanced/driver#%E9%85%8D%E7%BD%AE%E9%A9%B1%E5%8A%A8%E5%99%A8) 文档进行设置。 42 | > 43 | > 例如: 44 | > 45 | > DRIVER=~httpx 46 | > 47 | > DRIVER=~aiohttp 48 | > 49 | > DRIVER=~fastapi+~httpx 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 | | :----------------------- | ------------------------------------------------------------------ | :------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 76 | | 设置词云形状 | `/设置词云形状` | 超级用户/群主/管理员 | 发送一张图片作为当前群词云形状,每个群各自独立 | 77 | | 删除词云形状 | `/删除词云形状` | 超级用户/群主/管理员 | 删除本群词云形状 | 78 | | 设置词云默认形状 | `/设置词云默认形状` | 超级用户 | 发送一张图片作为所有词云的默认形状,每个群都会改变 | 79 | | 删除词云默认形状 | `/删除词云默认形状` | 超级用户 | 删除默认词云形状,继续使用词云默认的矩形 | 80 | | 开启词云每日定时发送 | `/开启词云每日定时发送` 或
`/开启词云每日定时发送` + `[时间]` | 超级用户/群主/管理员 | 开启本群每日定时发送词云,默认将在每天 `wordcloud_default_schedule_time` 设置的时间发送今日词云,
如果时间没有包含时区信息,则根据 `wordcloud_timezone` 配置项确定时区。
时间的格式为 [ISO 8601](https://docs.python.org/zh-cn/3/library/datetime.html#datetime.time.fromisoformat),例如:`开启词云每日定时发送 23:59:59` | 81 | | 关闭词云每日定时发送 | `/关闭词云每日定时发送` | 超级用户/群主/管理员 | 关闭本群词云每日定时发送 | 82 | | 查看词云每日定时发送状态 | `/词云每日定时发送状态` | 超级用户/群主/管理员 | 查看定时发送状态 | 83 | 84 | ## 配置项 85 | 86 | 配置方式:直接在 NoneBot **全局配置文件(.env)** 中添加以下配置项 87 | 88 | | 配置项 | 类型 | 默认值 | 说明 | 89 | | :------------------------------ | --------------------- | :---------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 90 | | wordcloud_width | int | `1920` | 生成图片的宽度 | 91 | | wordcloud_height | int | `1200` | 生成图片的高度 | 92 | | wordcloud_background_color | str | `black` | 生成图片的背景颜色 | 93 | | wordcloud_colormap | Union[str, List[str]] | `viridis` | 生成图片的字体 [色彩映射表](https://matplotlib.org/stable/tutorials/colors/colormaps.html)(当值为列表时会随机选择其中之一) | 94 | | wordcloud_font_path | str | 自带的字体(思源黑体) | 生成图片的字体文件位置 | 95 | | wordcloud_stopwords_path | str | None | 结巴分词的 [停用词表](https://github.com/fxsjy/jieba#%E5%9F%BA%E4%BA%8E-tf-idf-%E7%AE%97%E6%B3%95%E7%9A%84%E5%85%B3%E9%94%AE%E8%AF%8D%E6%8A%BD%E5%8F%96) 位置, 用来屏蔽某些词语
例如:`"./wordcloud_extra_dict/stopword.txt"`
表示屏蔽 **stopword.txt** 中的词语,
格式参考 [stop_words.txt](https://github.com/fxsjy/jieba/blob/master/extra_dict/stop_words.txt) | 96 | | wordcloud_userdict_path | str | None | 结巴分词的[自定义词典](https://github.com/fxsjy/jieba#%E8%BD%BD%E5%85%A5%E8%AF%8D%E5%85%B8) 位置 | 97 | | wordcloud_timezone | str | None | 用户自定义的 [时区](https://docs.python.org/zh-cn/3/library/zoneinfo.html),
留空则使用系统时区,具体数值可参考:[时区列表](https://timezonedb.com/time-zones),
例如:`Asia/Shanghai` | 98 | | wordcloud_default_schedule_time | str | `22:00` | 默认定时发送时间,当开启词云每日定时发送时没有提供具体时间,
将会在这个时间发送每日词云 | 99 | | wordcloud_options | `Dict[str, Any]` | `{}` | 向 [WordCloud](https://amueller.github.io/word_cloud/generated/wordcloud.WordCloud.html#wordcloud.WordCloud) 传递的参数。
拥有最高优先级,将会覆盖以上词云的配置项,
例如:`{"background_color":"black","max_words":2000,"contour_width":3, "contour_color":"steelblue"}` | 100 | | wordcloud_exclude_user_ids | `Set[str]` | `set()` | 排除的用户 ID 列表(全局,不区分平台),
例如:`["123456","456789"]` | 101 | | wordcloud_reply_message | bool | 发送词云图片时是否回复触发它的消息`False` | | 102 | 103 | ## 鸣谢 104 | 105 | 插件依赖 [nonebot-plugin-chatrecorder](https://github.com/MeetWq/nonebot-plugin-chatrecorder) 提供消息存储。 106 | 107 | 感谢以下开发者作出的贡献: 108 | 109 | 110 | contributors 111 | 112 | -------------------------------------------------------------------------------- /nonebot_plugin_wordcloud/SourceHanSans.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/he0119/nonebot-plugin-wordcloud/ef14367c14c6a50cb171834f577f4e9127d31bc6/nonebot_plugin_wordcloud/SourceHanSans.otf -------------------------------------------------------------------------------- /nonebot_plugin_wordcloud/__init__.py: -------------------------------------------------------------------------------- 1 | """词云""" 2 | 3 | from nonebot import require 4 | 5 | require("nonebot_plugin_apscheduler") 6 | require("nonebot_plugin_alconna") 7 | require("nonebot_plugin_uninfo") 8 | require("nonebot_plugin_cesaa") 9 | 10 | import re 11 | from datetime import datetime, timedelta 12 | from io import BytesIO 13 | from typing import Any, Optional, Union 14 | 15 | import nonebot_plugin_alconna as alc 16 | import nonebot_plugin_saa as saa 17 | from arclet.alconna import ArparmaBehavior 18 | from arclet.alconna.arparma import Arparma 19 | from nonebot import get_driver 20 | from nonebot.adapters import Bot, Event, Message 21 | from nonebot.params import Arg, Depends 22 | from nonebot.permission import SUPERUSER 23 | from nonebot.plugin import PluginMetadata, inherit_supported_adapters 24 | from nonebot.typing import T_State 25 | from nonebot_plugin_alconna import ( 26 | Alconna, 27 | AlconnaMatch, 28 | AlconnaMatcher, 29 | AlconnaQuery, 30 | Args, 31 | CommandMeta, 32 | Match, 33 | Option, 34 | Query, 35 | image_fetch, 36 | on_alconna, 37 | store_true, 38 | ) 39 | from nonebot_plugin_cesaa import get_messages_plain_text 40 | from nonebot_plugin_uninfo import Session, UniSession 41 | from PIL import Image 42 | 43 | from . import migrations 44 | from .config import Config, plugin_config 45 | from .data_source import get_wordcloud 46 | from .schedule import schedule_service 47 | from .utils import ( 48 | admin_permission, 49 | ensure_group, 50 | get_datetime_fromisoformat_with_timezone, 51 | get_datetime_now_with_timezone, 52 | get_mask_key, 53 | get_time_fromisoformat_with_timezone, 54 | ) 55 | 56 | get_driver().on_startup(schedule_service.update) 57 | 58 | __plugin_meta__ = PluginMetadata( 59 | name="词云", 60 | description="利用群消息生成词云", 61 | usage="""\ 62 | - 通过快捷命令,以获取常见时间段内的词云 63 | 格式:/<时间段>词云 64 | 时间段关键词有:今日,昨日,本周,上周,本月,上月,年度 65 | 示例:/今日词云,/昨日词云 66 | 67 | - 提供日期与时间,以获取指定时间段内的词云(支持 ISO8601 格式的日期与时间,如 2022-02-22T22:22:22) 68 | 格式:/历史词云 [日期或时间段] 69 | 示例:/历史词云 70 | /历史词云 2022-01-01 71 | /历史词云 2022-01-01~2022-02-22 72 | /历史词云 2022-02-22T11:11:11~2022-02-22T22:22:22 73 | 74 | - 在上方所给的命令格式基础上,还可以添加前缀“我的”,以获取自己的词云 75 | 格式:/我的<基本命令格式> 76 | 示例:/我的今日词云 77 | /我的昨日词云 78 | 79 | - 设置自定义词云形状 80 | 格式:/设置词云形状 81 | /设置词云形状 82 | 83 | - 设置默认词云形状(仅超级用户) 84 | 格式:/设置词云默认形状 85 | /删除词云默认形状 86 | 87 | - 设置定时发送每日词云 88 | 格式:/词云每日定时发送状态 89 | /开启词云每日定时发送 90 | /开启词云每日定时发送 23:59 91 | /关闭词云每日定时发送""", # noqa: E501 92 | homepage="https://github.com/he0119/nonebot-plugin-wordcloud", 93 | type="application", 94 | supported_adapters=inherit_supported_adapters( 95 | "nonebot_plugin_chatrecorder", "nonebot_plugin_saa", "nonebot_plugin_alconna" 96 | ), 97 | config=Config, 98 | extra={"orm_version_location": migrations}, 99 | ) 100 | 101 | 102 | class SameTime(ArparmaBehavior): 103 | def operate(self, interface: Arparma): 104 | type = interface.query("type") 105 | time = interface.query("time") 106 | if type is None and time: 107 | interface.behave_fail() 108 | 109 | 110 | wordcloud_cmd = on_alconna( 111 | Alconna( 112 | "词云", 113 | Option( 114 | "--my", 115 | default=False, 116 | action=store_true, 117 | help_text="获取自己的词云", 118 | ), 119 | Args["type?", ["今日", "昨日", "本周", "上周", "本月", "上月", "年度", "历史"]][ 120 | "time?", str 121 | ], 122 | behaviors=[SameTime()], 123 | meta=CommandMeta( 124 | description="利用群消息生成词云", 125 | usage=( 126 | "- 通过快捷命令,以获取常见时间段内的词云\n" 127 | "格式:/<时间段>词云\n" 128 | "时间段关键词有:今日,昨日,本周,上周,本月,上月,年度\n" 129 | "- 提供日期与时间,以获取指定时间段内的词云\n" 130 | "(支持 ISO8601 格式的日期与时间,如 2022-02-22T22:22:22)\n" 131 | "格式:/历史词云 [日期或时间段]" 132 | ), 133 | example=( 134 | "/今日词云\n" 135 | "/昨日词云\n" 136 | "/历史词云\n" 137 | "/历史词云 2022-01-01\n" 138 | "/历史词云 2022-01-01~2022-02-22\n" 139 | "/历史词云 2022-02-22T11:11:11~2022-02-22T22:22:22" 140 | ), 141 | ), 142 | ), 143 | use_cmd_start=True, 144 | block=True, 145 | ) 146 | 147 | 148 | def wrapper( 149 | slot: Union[int, str], content: Optional[str], context: dict[str, Any] 150 | ) -> str: 151 | if slot == "my" and content: 152 | return "--my" 153 | elif slot == "type" and content: 154 | return content 155 | return "" # pragma: no cover 156 | 157 | 158 | wordcloud_cmd.shortcut( 159 | r"(?P我的)?(?P今日|昨日|本周|上周|本月|上月|年度|历史)词云", 160 | { 161 | "prefix": True, 162 | "command": "词云", 163 | "wrapper": wrapper, 164 | "args": ["{my}", "{type}"], 165 | "humanized": "[我的]<类型>词云", 166 | }, 167 | ) 168 | 169 | 170 | def parse_datetime(key: str): 171 | """解析数字,并将结果存入 state 中""" 172 | 173 | async def _key_parser( 174 | matcher: AlconnaMatcher, 175 | state: T_State, 176 | input: Union[datetime, Message] = Arg(key), 177 | ): 178 | if isinstance(input, datetime): 179 | return 180 | 181 | plaintext = input.extract_plain_text() 182 | try: 183 | state[key] = get_datetime_fromisoformat_with_timezone(plaintext) 184 | except ValueError: 185 | await matcher.reject_arg(key, "请输入正确的日期,不然我没法理解呢!") 186 | 187 | return _key_parser 188 | 189 | 190 | @wordcloud_cmd.handle(parameterless=[Depends(ensure_group)]) 191 | async def handle_first_receive( 192 | state: T_State, type: Optional[str] = None, time: Optional[str] = None 193 | ): 194 | dt = get_datetime_now_with_timezone() 195 | 196 | if not type: 197 | await wordcloud_cmd.finish(__plugin_meta__.usage) 198 | 199 | if type == "今日": 200 | state["start"] = dt.replace(hour=0, minute=0, second=0, microsecond=0) 201 | state["stop"] = dt 202 | elif type == "昨日": 203 | state["stop"] = dt.replace(hour=0, minute=0, second=0, microsecond=0) 204 | state["start"] = state["stop"] - timedelta(days=1) 205 | elif type == "本周": 206 | state["start"] = dt.replace( 207 | hour=0, minute=0, second=0, microsecond=0 208 | ) - timedelta(days=dt.weekday()) 209 | state["stop"] = dt 210 | elif type == "上周": 211 | state["stop"] = dt.replace( 212 | hour=0, minute=0, second=0, microsecond=0 213 | ) - timedelta(days=dt.weekday()) 214 | state["start"] = state["stop"] - timedelta(days=7) 215 | elif type == "本月": 216 | state["start"] = dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) 217 | state["stop"] = dt 218 | elif type == "上月": 219 | state["stop"] = dt.replace( 220 | day=1, hour=0, minute=0, second=0, microsecond=0 221 | ) - timedelta(microseconds=1) 222 | state["start"] = state["stop"].replace( 223 | day=1, hour=0, minute=0, second=0, microsecond=0 224 | ) 225 | elif type == "年度": 226 | state["start"] = dt.replace( 227 | month=1, day=1, hour=0, minute=0, second=0, microsecond=0 228 | ) 229 | state["stop"] = dt 230 | elif type == "历史": 231 | if time: 232 | plaintext = time 233 | if match := re.match(r"^(.+?)(?:~(.+))?$", plaintext): 234 | start = match[1] 235 | stop = match[2] 236 | try: 237 | state["start"] = get_datetime_fromisoformat_with_timezone(start) 238 | if stop: 239 | state["stop"] = get_datetime_fromisoformat_with_timezone(stop) 240 | else: 241 | # 如果没有指定结束日期,则认为是所给日期的当天的词云 242 | state["start"] = state["start"].replace( 243 | hour=0, minute=0, second=0, microsecond=0 244 | ) 245 | state["stop"] = state["start"] + timedelta(days=1) 246 | except ValueError: 247 | await wordcloud_cmd.finish("请输入正确的日期,不然我没法理解呢!") 248 | 249 | 250 | @wordcloud_cmd.got( 251 | "start", 252 | prompt="请输入你要查询的起始日期(如 2022-01-01)", 253 | parameterless=[Depends(parse_datetime("start"))], 254 | ) 255 | @wordcloud_cmd.got( 256 | "stop", 257 | prompt="请输入你要查询的结束日期(如 2022-02-22)", 258 | parameterless=[Depends(parse_datetime("stop"))], 259 | ) 260 | async def handle_wordcloud( 261 | my: Query[bool] = AlconnaQuery("my.value", False), 262 | session: Session = UniSession(), 263 | start: datetime = Arg(), 264 | stop: datetime = Arg(), 265 | mask_key: str = Depends(get_mask_key), 266 | ): 267 | """生成词云""" 268 | messages = await get_messages_plain_text( 269 | session=session, 270 | filter_user=my.result, 271 | filter_self_id=False, 272 | filter_adapter=False, 273 | types=["message"], # 排除机器人自己发的消息 274 | time_start=start, 275 | time_stop=stop, 276 | exclude_user_ids=plugin_config.wordcloud_exclude_user_ids, 277 | ) 278 | 279 | if not (image := await get_wordcloud(messages, mask_key)): 280 | await wordcloud_cmd.finish( 281 | "没有足够的数据生成词云", 282 | at_sender=my.result, 283 | reply=plugin_config.wordcloud_reply_message, 284 | ) 285 | 286 | await saa.Image(image, "wordcloud.png").finish( 287 | at_sender=my.result, 288 | reply=plugin_config.wordcloud_reply_message, 289 | ) 290 | 291 | 292 | set_mask_cmd = on_alconna( 293 | Alconna( 294 | "设置词云形状", 295 | Option("--default", default=False, action=store_true, help_text="默认形状"), 296 | Args["img?", alc.Image], 297 | meta=CommandMeta( 298 | description="设置自定义词云形状", 299 | example="/设置词云形状\n/设置词云默认形状", 300 | ), 301 | ), 302 | permission=admin_permission(), 303 | use_cmd_start=True, 304 | block=True, 305 | ) 306 | set_mask_cmd.shortcut( 307 | "设置词云默认形状", 308 | { 309 | "prefix": True, 310 | "command": "设置词云形状", 311 | "args": ["--default"], 312 | }, 313 | ) 314 | 315 | 316 | @set_mask_cmd.handle(parameterless=[Depends(ensure_group)]) 317 | async def _( 318 | matcher: AlconnaMatcher, 319 | img: Match[bytes] = AlconnaMatch("img", image_fetch), 320 | ): 321 | if img.available: 322 | matcher.set_path_arg("img", img.result) 323 | 324 | 325 | @set_mask_cmd.got_path("img", "请发送一张图片作为词云形状", image_fetch) 326 | async def handle_save_mask( 327 | bot: Bot, 328 | event: Event, 329 | img: bytes, 330 | default: Query[bool] = AlconnaQuery("default.value", default=False), 331 | mask_key: str = Depends(get_mask_key), 332 | ): 333 | mask = Image.open(BytesIO(img)) 334 | if default.result: 335 | if not await SUPERUSER(bot, event): 336 | await set_mask_cmd.finish("仅超级用户可设置词云默认形状") 337 | mask.save(plugin_config.get_mask_path(), format="PNG") 338 | await set_mask_cmd.finish("词云默认形状设置成功") 339 | else: 340 | mask.save(plugin_config.get_mask_path(mask_key), format="PNG") 341 | await set_mask_cmd.finish("词云形状设置成功") 342 | 343 | 344 | remove_mask_cmd = on_alconna( 345 | Alconna( 346 | "删除词云形状", 347 | Option("--default", default=False, action=store_true, help_text="默认形状"), 348 | meta=CommandMeta( 349 | description="删除自定义词云形状", 350 | example="/删除词云形状\n/删除词云默认形状", 351 | ), 352 | ), 353 | permission=admin_permission(), 354 | use_cmd_start=True, 355 | block=True, 356 | ) 357 | remove_mask_cmd.shortcut( 358 | "删除词云默认形状", 359 | { 360 | "prefix": True, 361 | "command": "删除词云形状", 362 | "args": ["--default"], 363 | }, 364 | ) 365 | 366 | 367 | @remove_mask_cmd.handle(parameterless=[Depends(ensure_group)]) 368 | async def _( 369 | bot: Bot, 370 | event: Event, 371 | default: Query[bool] = AlconnaQuery("default.value", default=False), 372 | mask_key: str = Depends(get_mask_key), 373 | ): 374 | if default.result: 375 | if not await SUPERUSER(bot, event): 376 | await remove_mask_cmd.finish("仅超级用户可删除词云默认形状") 377 | mask_path = plugin_config.get_mask_path() 378 | mask_path.unlink(missing_ok=True) 379 | await remove_mask_cmd.finish("词云默认形状已删除") 380 | else: 381 | mask_path = plugin_config.get_mask_path(mask_key) 382 | mask_path.unlink(missing_ok=True) 383 | await remove_mask_cmd.finish("词云形状已删除") 384 | 385 | 386 | schedule_cmd = on_alconna( 387 | Alconna( 388 | "词云定时发送", 389 | Option( 390 | "--action", 391 | Args["action_type", ["状态", "开启", "关闭"]], 392 | default="状态", 393 | help_text="操作类型", 394 | ), 395 | Args["type", ["每日"]]["time?", str], 396 | meta=CommandMeta( 397 | description="设置定时发送词云", 398 | usage="当前仅支持每日定时发送", 399 | example=( 400 | "/词云每日定时发送状态\n" 401 | "/开启词云每日定时发送\n" 402 | "/开启词云每日定时发送 23:59\n" 403 | "/关闭词云每日定时发送" 404 | ), 405 | ), 406 | ), 407 | permission=admin_permission(), 408 | use_cmd_start=True, 409 | block=True, 410 | ) 411 | schedule_cmd.shortcut( 412 | r"词云(?P每日)定时发送状态", 413 | { 414 | "prefix": True, 415 | "command": "词云定时发送", 416 | "args": ["--action", "状态", "{type}"], 417 | "humanized": "词云每日定时发送状态", 418 | }, 419 | ) 420 | schedule_cmd.shortcut( 421 | r"(?P开启|关闭)词云(?P每日)定时发送", 422 | { 423 | "prefix": True, 424 | "command": "词云定时发送", 425 | "args": ["--action", "{action}", "{type}"], 426 | "humanized": "<开启|关闭>词云每日定时发送", 427 | }, 428 | ) 429 | 430 | 431 | @schedule_cmd.handle(parameterless=[Depends(ensure_group)]) 432 | async def _( 433 | time: Optional[str] = None, 434 | action_type: Query[str] = AlconnaQuery("action.action_type.value", "状态"), 435 | target: saa.PlatformTarget = Depends(saa.get_target), 436 | ): 437 | if action_type.result == "状态": 438 | schedule_time = await schedule_service.get_schedule(target) 439 | await schedule_cmd.finish( 440 | f"词云每日定时发送已开启,发送时间为:{schedule_time}" 441 | if schedule_time 442 | else "词云每日定时发送未开启" 443 | ) 444 | elif action_type.result == "开启": 445 | schedule_time = None 446 | if time: 447 | try: 448 | schedule_time = get_time_fromisoformat_with_timezone(time) 449 | except ValueError: 450 | await schedule_cmd.finish("请输入正确的时间,不然我没法理解呢!") 451 | await schedule_service.add_schedule(target, time=schedule_time) 452 | await schedule_cmd.finish( 453 | f"已开启词云每日定时发送,发送时间为:{schedule_time}" 454 | if schedule_time 455 | else f"已开启词云每日定时发送,发送时间为:{plugin_config.wordcloud_default_schedule_time}" # noqa: E501 456 | ) 457 | elif action_type.result == "关闭": 458 | await schedule_service.remove_schedule(target) 459 | await schedule_cmd.finish("已关闭词云每日定时发送") 460 | -------------------------------------------------------------------------------- /nonebot_plugin_wordcloud/compat.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, overload 2 | 3 | from nonebot.compat import PYDANTIC_V2 4 | 5 | __all__ = ("model_validator",) 6 | 7 | 8 | if PYDANTIC_V2: 9 | from pydantic import model_validator as model_validator 10 | else: 11 | from pydantic import root_validator 12 | 13 | @overload 14 | def model_validator(*, mode: Literal["before"]): ... 15 | 16 | @overload 17 | def model_validator(*, mode: Literal["after"]): ... 18 | 19 | def model_validator(*, mode: Literal["before", "after"]): 20 | return root_validator(pre=mode == "before", allow_reuse=True) 21 | -------------------------------------------------------------------------------- /nonebot_plugin_wordcloud/config.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, time 2 | from pathlib import Path 3 | from typing import Any, Optional, Union 4 | from zoneinfo import ZoneInfo 5 | 6 | from nonebot import get_driver, get_plugin_config 7 | from nonebot_plugin_localstore import get_data_dir 8 | from pydantic import BaseModel 9 | 10 | from .compat import model_validator 11 | 12 | DATA_DIR = get_data_dir("nonebot_plugin_wordcloud") 13 | 14 | 15 | class Config(BaseModel): 16 | wordcloud_width: int = 1920 17 | wordcloud_height: int = 1200 18 | wordcloud_background_color: str = "black" 19 | wordcloud_colormap: Union[str, list[str]] = "viridis" 20 | wordcloud_font_path: str 21 | wordcloud_stopwords_path: Optional[Path] = None 22 | wordcloud_userdict_path: Optional[Path] = None 23 | wordcloud_timezone: Optional[str] = None 24 | wordcloud_default_schedule_time: time 25 | """ 默认定时发送时间 26 | 27 | 如果群内不单独设置则使用这个值,默认为晚上 10 点,时区为设定的时区 28 | """ 29 | wordcloud_options: dict[str, Any] = {} 30 | wordcloud_exclude_user_ids: set[str] = set() 31 | """排除的用户 ID 列表(全局,不区分平台)""" 32 | wordcloud_reply_message: bool = False 33 | """是否回复消息,默认不回复""" 34 | 35 | @model_validator(mode="before") 36 | def set_default_values(cls, values): 37 | if not values.get("wordcloud_font_path"): 38 | values["wordcloud_font_path"] = str( 39 | Path(__file__).parent / "SourceHanSans.otf" 40 | ) 41 | 42 | default_schedule_time = ( 43 | time.fromisoformat(wordcloud_default_schedule_time) 44 | if ( 45 | wordcloud_default_schedule_time := values.get( 46 | "wordcloud_default_schedule_time" 47 | ) 48 | ) 49 | else time(22, 0, 0) 50 | ) 51 | 52 | default_schedule_time = ( 53 | default_schedule_time.replace(tzinfo=ZoneInfo(wordcloud_timezone)) 54 | if (wordcloud_timezone := values.get("wordcloud_timezone")) 55 | else default_schedule_time.replace( 56 | tzinfo=datetime.now().astimezone().tzinfo 57 | ) 58 | ) 59 | values["wordcloud_default_schedule_time"] = default_schedule_time 60 | return values 61 | 62 | def get_mask_path(self, key: Optional[str] = None) -> Path: 63 | """获取 mask 文件路径""" 64 | if key is None: 65 | return DATA_DIR / "mask.png" 66 | return DATA_DIR / f"mask-{key}.png" 67 | 68 | 69 | global_config = get_driver().config 70 | plugin_config = get_plugin_config(Config) 71 | -------------------------------------------------------------------------------- /nonebot_plugin_wordcloud/data_source.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import concurrent.futures 3 | import contextlib 4 | import re 5 | from functools import partial 6 | from io import BytesIO 7 | from random import choice 8 | from typing import Optional 9 | 10 | import jieba 11 | import jieba.analyse 12 | import numpy as np 13 | from emoji import replace_emoji 14 | from PIL import Image 15 | from wordcloud import WordCloud 16 | 17 | from .config import global_config, plugin_config 18 | 19 | 20 | def pre_precess(msg: str) -> str: 21 | """对消息进行预处理""" 22 | # 去除网址 23 | # https://stackoverflow.com/a/17773849/9212748 24 | url_regex = re.compile( 25 | r"(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]" 26 | r"+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})" 27 | ) 28 | msg = url_regex.sub("", msg) 29 | 30 | # 去除 \u200b 31 | msg = re.sub(r"\u200b", "", msg) 32 | 33 | # 去除 emoji 34 | # https://github.com/carpedm20/emoji 35 | msg = replace_emoji(msg) 36 | 37 | return msg 38 | 39 | 40 | def analyse_message(msg: str) -> dict[str, float]: 41 | """分析消息 42 | 43 | 分词,并统计词频 44 | """ 45 | # 设置停用词表 46 | if plugin_config.wordcloud_stopwords_path: 47 | jieba.analyse.set_stop_words(plugin_config.wordcloud_stopwords_path) 48 | # 加载用户词典 49 | if plugin_config.wordcloud_userdict_path: 50 | jieba.load_userdict(str(plugin_config.wordcloud_userdict_path)) 51 | # 基于 TF-IDF 算法的关键词抽取 52 | # 返回所有关键词,因为设置了数量其实也只是 tags[:topK],不如交给词云库处理 53 | words = jieba.analyse.extract_tags(msg, topK=0, withWeight=True) 54 | return dict(words) 55 | 56 | 57 | def get_mask(key: str): 58 | """获取 mask""" 59 | mask_path = plugin_config.get_mask_path(key) 60 | if mask_path.exists(): 61 | return np.array(Image.open(mask_path)) 62 | # 如果指定 mask 文件不存在,则尝试默认 mask 63 | default_mask_path = plugin_config.get_mask_path() 64 | if default_mask_path.exists(): 65 | return np.array(Image.open(default_mask_path)) 66 | 67 | 68 | def _get_wordcloud(messages: list[str], mask_key: str) -> Optional[bytes]: 69 | # 过滤掉命令 70 | command_start = tuple(i for i in global_config.command_start if i) 71 | message = " ".join(m for m in messages if not m.startswith(command_start)) 72 | # 预处理 73 | message = pre_precess(message) 74 | # 分析消息。分词,并统计词频 75 | frequency = analyse_message(message) 76 | # 词云参数 77 | wordcloud_options = {} 78 | wordcloud_options.update(plugin_config.wordcloud_options) 79 | wordcloud_options.setdefault("font_path", str(plugin_config.wordcloud_font_path)) 80 | wordcloud_options.setdefault("width", plugin_config.wordcloud_width) 81 | wordcloud_options.setdefault("height", plugin_config.wordcloud_height) 82 | wordcloud_options.setdefault( 83 | "background_color", plugin_config.wordcloud_background_color 84 | ) 85 | # 如果 colormap 是列表,则随机选择一个 86 | colormap = ( 87 | plugin_config.wordcloud_colormap 88 | if isinstance(plugin_config.wordcloud_colormap, str) 89 | else choice(plugin_config.wordcloud_colormap) 90 | ) 91 | wordcloud_options.setdefault("colormap", colormap) 92 | wordcloud_options.setdefault("mask", get_mask(mask_key)) 93 | with contextlib.suppress(ValueError): 94 | wordcloud = WordCloud(**wordcloud_options) 95 | image = wordcloud.generate_from_frequencies(frequency).to_image() 96 | image_bytes = BytesIO() 97 | image.save(image_bytes, format="PNG") 98 | return image_bytes.getvalue() 99 | 100 | 101 | async def get_wordcloud(messages: list[str], mask_key: str) -> Optional[bytes]: 102 | loop = asyncio.get_running_loop() 103 | pfunc = partial(_get_wordcloud, messages, mask_key) 104 | # 虽然不知道具体是哪里泄漏了,但是通过每次关闭线程池可以避免这个问题 105 | # https://github.com/he0119/nonebot-plugin-wordcloud/issues/99 106 | with concurrent.futures.ThreadPoolExecutor() as pool: 107 | return await loop.run_in_executor(pool, pfunc) 108 | -------------------------------------------------------------------------------- /nonebot_plugin_wordcloud/migrations/557fef3a156f_init_db.py: -------------------------------------------------------------------------------- 1 | """init db 2 | 3 | 修订 ID: 557fef3a156f 4 | 父修订: 5 | 创建时间: 2023-10-10 18:16:41.811561 6 | 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from typing import TYPE_CHECKING 12 | 13 | import sqlalchemy as sa 14 | from alembic import op 15 | from sqlalchemy.dialects import postgresql 16 | 17 | if TYPE_CHECKING: 18 | from collections.abc import Sequence 19 | 20 | revision: str = "557fef3a156f" 21 | down_revision: str | Sequence[str] | None = None 22 | branch_labels: str | Sequence[str] | None = ("nonebot_plugin_wordcloud",) 23 | depends_on: str | Sequence[str] | None = None 24 | 25 | 26 | def upgrade(name: str = "") -> None: 27 | if name: 28 | return 29 | # ### commands auto generated by Alembic - please adjust! ### 30 | op.create_table( 31 | "nonebot_plugin_wordcloud_schedule", 32 | sa.Column("id", sa.Integer(), nullable=False), 33 | sa.Column( 34 | "target", 35 | sa.JSON().with_variant(postgresql.JSONB(), "postgresql"), 36 | nullable=False, 37 | ), 38 | sa.Column("time", sa.Time(), nullable=True), 39 | sa.PrimaryKeyConstraint( 40 | "id", name=op.f("pk_nonebot_plugin_wordcloud_schedule") 41 | ), 42 | ) 43 | # ### end Alembic commands ### 44 | 45 | 46 | def downgrade(name: str = "") -> None: 47 | if name: 48 | return 49 | # ### commands auto generated by Alembic - please adjust! ### 50 | op.drop_table("nonebot_plugin_wordcloud_schedule") 51 | # ### end Alembic commands ### 52 | -------------------------------------------------------------------------------- /nonebot_plugin_wordcloud/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/he0119/nonebot-plugin-wordcloud/ef14367c14c6a50cb171834f577f4e9127d31bc6/nonebot_plugin_wordcloud/migrations/__init__.py -------------------------------------------------------------------------------- /nonebot_plugin_wordcloud/migrations/ade8cdca5470_migrate_datastore_data.py: -------------------------------------------------------------------------------- 1 | """migrate datastore data 2 | 3 | 修订 ID: ade8cdca5470 4 | 父修订: 557fef3a156f 5 | 创建时间: 2023-10-11 19:57:51.023847 6 | 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from typing import TYPE_CHECKING 12 | 13 | from alembic import op 14 | from alembic.op import run_async 15 | from nonebot import logger, require 16 | from sqlalchemy import Connection, inspect, select 17 | from sqlalchemy.ext.automap import automap_base 18 | from sqlalchemy.orm import Session 19 | 20 | if TYPE_CHECKING: 21 | from collections.abc import Sequence 22 | 23 | from sqlalchemy.ext.asyncio import AsyncConnection 24 | 25 | revision: str = "ade8cdca5470" 26 | down_revision: str | Sequence[str] | None = "557fef3a156f" 27 | branch_labels: str | Sequence[str] | None = None 28 | depends_on: str | Sequence[str] | None = None 29 | 30 | 31 | def _has_table(conn: Connection, table_name: str) -> bool: 32 | insp = inspect(conn) 33 | return table_name in insp.get_table_names() 34 | 35 | 36 | def _migrate_old_data(ds_conn: Connection): 37 | insp = inspect(ds_conn) 38 | if ( 39 | "nonebot_plugin_wordcloud_schedule" not in insp.get_table_names() 40 | or "nonebot_plugin_wordcloud_alembic_version" not in insp.get_table_names() 41 | ): 42 | logger.info("wordcloud: 未发现来自 datastore 的数据") 43 | return 44 | 45 | DsBase = automap_base() 46 | DsBase.prepare(autoload_with=ds_conn) 47 | DsSchedule = DsBase.classes.nonebot_plugin_wordcloud_schedule 48 | 49 | Base = automap_base() 50 | Base.prepare(autoload_with=op.get_bind()) 51 | Schedule = Base.classes.nonebot_plugin_wordcloud_schedule 52 | 53 | ds_session = Session(ds_conn) 54 | session = Session(op.get_bind()) 55 | 56 | count = ds_session.query(DsSchedule).count() 57 | if count == 0: 58 | logger.info("wordcloud: 未发现来自 datastore 的数据") 59 | return 60 | 61 | AlembicVersion = DsBase.classes.nonebot_plugin_wordcloud_alembic_version 62 | version_num = ds_session.scalars(select(AlembicVersion.version_num)).one_or_none() 63 | if not version_num: 64 | return 65 | if version_num != "c0ecb94cc7a0": 66 | logger.warning( 67 | "wordcloud: 发现旧版本的数据,请先安装 0.5.2 版本," 68 | "并运行 nb datastore upgrade 完成数据迁移之后再安装新版本" 69 | ) 70 | raise RuntimeError("wordcloud: 请先安装 0.5.2 版本完成迁移之后再升级") 71 | 72 | # 写入数据 73 | logger.info("wordcloud: 发现来自 datastore 的数据,正在迁移...") 74 | schedules = ds_session.query(DsSchedule).all() 75 | for schedule in schedules: 76 | session.add( 77 | Schedule( 78 | id=schedule.id, 79 | target=schedule.target, 80 | time=schedule.time, 81 | ) 82 | ) 83 | session.commit() 84 | logger.info("wordcloud: 迁移完成") 85 | 86 | 87 | async def data_migrate(conn: AsyncConnection): 88 | from nonebot_plugin_datastore.db import get_engine 89 | 90 | async with get_engine().connect() as ds_conn: 91 | await ds_conn.run_sync(_migrate_old_data) 92 | 93 | 94 | def upgrade(name: str = "") -> None: 95 | if name: 96 | return 97 | # ### commands auto generated by Alembic - please adjust! ### 98 | try: 99 | require("nonebot_plugin_datastore") 100 | except RuntimeError: 101 | return 102 | 103 | run_async(data_migrate) 104 | # ### end Alembic commands ### 105 | 106 | 107 | def downgrade(name: str = "") -> None: 108 | if name: 109 | return 110 | # ### commands auto generated by Alembic - please adjust! ### 111 | pass 112 | # ### end Alembic commands ### 113 | -------------------------------------------------------------------------------- /nonebot_plugin_wordcloud/model.py: -------------------------------------------------------------------------------- 1 | from datetime import time 2 | from typing import Optional 3 | 4 | from nonebot_plugin_orm import Model 5 | from nonebot_plugin_saa import PlatformTarget 6 | from sqlalchemy import JSON 7 | from sqlalchemy.dialects.postgresql import JSONB 8 | from sqlalchemy.orm import Mapped, mapped_column 9 | 10 | 11 | class Schedule(Model): 12 | """定时发送""" 13 | 14 | id: Mapped[int] = mapped_column(primary_key=True) 15 | target: Mapped[dict] = mapped_column(JSON().with_variant(JSONB, "postgresql")) 16 | time: Mapped[Optional[time]] 17 | """ UTC 时间 """ 18 | 19 | @property 20 | def saa_target(self) -> PlatformTarget: 21 | return PlatformTarget.deserialize(self.target) 22 | -------------------------------------------------------------------------------- /nonebot_plugin_wordcloud/schedule.py: -------------------------------------------------------------------------------- 1 | from datetime import time 2 | from typing import TYPE_CHECKING, Optional 3 | from zoneinfo import ZoneInfo 4 | 5 | import nonebot_plugin_saa as saa 6 | from nonebot.compat import model_dump 7 | from nonebot.log import logger 8 | from nonebot_plugin_apscheduler import scheduler 9 | from nonebot_plugin_cesaa import get_messages_plain_text 10 | from nonebot_plugin_orm import get_session 11 | from sqlalchemy import JSON, Select, cast, select 12 | from sqlalchemy.ext.asyncio import AsyncSession 13 | 14 | from .config import plugin_config 15 | from .data_source import get_wordcloud 16 | from .model import Schedule 17 | from .utils import ( 18 | get_datetime_now_with_timezone, 19 | get_mask_key, 20 | get_time_with_scheduler_timezone, 21 | time_astimezone, 22 | ) 23 | 24 | if TYPE_CHECKING: 25 | from apscheduler.job import Job 26 | 27 | saa.enable_auto_select_bot() 28 | 29 | 30 | class Scheduler: 31 | def __init__(self): 32 | # 默认定时任务的 key 为 default 33 | # 其他则为 ISO 8601 格式的时间字符串 34 | self.schedules: dict[str, Job] = {} 35 | 36 | # 转换到 APScheduler 的时区 37 | scheduler_time = get_time_with_scheduler_timezone( 38 | plugin_config.wordcloud_default_schedule_time 39 | ) 40 | # 添加默认定时任务 41 | self.schedules["default"] = scheduler.add_job( 42 | self.run_task, 43 | "cron", 44 | hour=scheduler_time.hour, 45 | minute=scheduler_time.minute, 46 | second=scheduler_time.second, 47 | ) 48 | 49 | async def update(self): 50 | """更新定时任务""" 51 | async with get_session() as session: 52 | statement = ( 53 | select(Schedule.time) 54 | .group_by(Schedule.time) 55 | .where(Schedule.time != None) # noqa: E711 56 | ) 57 | schedule_times = await session.scalars(statement) 58 | for schedule_time in schedule_times: 59 | assert schedule_time is not None 60 | time_str = schedule_time.isoformat() 61 | if time_str not in self.schedules: 62 | # 转换到 APScheduler 的时区,因为数据库中的时间是 UTC 时间 63 | scheduler_time = get_time_with_scheduler_timezone( 64 | schedule_time.replace(tzinfo=ZoneInfo("UTC")) 65 | ) 66 | self.schedules[time_str] = scheduler.add_job( 67 | self.run_task, 68 | "cron", 69 | hour=scheduler_time.hour, 70 | minute=scheduler_time.minute, 71 | second=scheduler_time.second, 72 | args=(schedule_time,), 73 | ) 74 | logger.debug( 75 | f"已添加每日词云定时发送任务,发送时间:{time_str} UTC" 76 | ) 77 | 78 | async def run_task(self, time: Optional[time] = None): 79 | """执行定时任务 80 | 81 | 时间为 UTC 时间,并且没有时区信息 82 | 如果没有传入时间,则执行默认定时任务 83 | """ 84 | async with get_session() as session: 85 | statement = select(Schedule).where(Schedule.time == time) 86 | results = await session.scalars(statement) 87 | schedules = results.all() 88 | # 如果该时间没有需要执行的定时任务,且不是默认任务则从任务列表中删除该任务 89 | if time and not schedules: 90 | self.schedules.pop(time.isoformat()).remove() 91 | return 92 | logger.info(f"开始发送每日词云,时间为 {time or '默认时间'}") 93 | for schedule in schedules: 94 | target = schedule.saa_target 95 | dt = get_datetime_now_with_timezone() 96 | start = dt.replace(hour=0, minute=0, second=0, microsecond=0) 97 | stop = dt 98 | messages = await get_messages_plain_text( 99 | target=target, 100 | types=["message"], 101 | time_start=start, 102 | time_stop=stop, 103 | exclude_user_ids=plugin_config.wordcloud_exclude_user_ids, 104 | ) 105 | mask_key = get_mask_key(target) 106 | 107 | if image := await get_wordcloud(messages, mask_key): 108 | msg = saa.Image(image) 109 | else: 110 | msg = saa.Text("今天没有足够的数据生成词云") 111 | 112 | try: 113 | await msg.send_to(target) 114 | except Exception: 115 | logger.exception(f"{target} 发送词云失败") 116 | 117 | async def get_schedule(self, target: saa.PlatformTarget) -> Optional[time]: 118 | """获取定时任务时间""" 119 | async with get_session() as session: 120 | statement = self.select_target_statement(target, session) 121 | results = await session.scalars(statement) 122 | if schedule := results.one_or_none(): 123 | if schedule.time: 124 | # 将时间转换为本地时间 125 | return time_astimezone( 126 | schedule.time.replace(tzinfo=ZoneInfo("UTC")) 127 | ) 128 | else: 129 | return plugin_config.wordcloud_default_schedule_time 130 | 131 | async def add_schedule( 132 | self, target: saa.PlatformTarget, *, time: Optional[time] = None 133 | ): 134 | """添加定时任务 135 | 136 | 时间需要带时区信息 137 | """ 138 | # 将时间转换为 UTC 时间 139 | if time: 140 | time = time_astimezone(time, ZoneInfo("UTC")) 141 | 142 | async with get_session() as session: 143 | statement = self.select_target_statement(target, session) 144 | results = await session.scalars(statement) 145 | if schedule := results.one_or_none(): 146 | schedule.time = time 147 | else: 148 | schedule = Schedule(time=time, target=model_dump(target)) 149 | session.add(schedule) 150 | await session.commit() 151 | await self.update() 152 | 153 | async def remove_schedule(self, target: saa.PlatformTarget): 154 | """删除定时任务""" 155 | async with get_session() as session: 156 | statement = self.select_target_statement(target, session) 157 | results = await session.scalars(statement) 158 | if schedule := results.one_or_none(): 159 | await session.delete(schedule) 160 | await session.commit() 161 | 162 | @staticmethod 163 | def select_target_statement( 164 | target: saa.PlatformTarget, session: AsyncSession 165 | ) -> Select[tuple[Schedule]]: 166 | """获取查询目标的语句 167 | 168 | MySQL 需要手动将 JSON 类型的字段转换为 JSON 类型 169 | """ 170 | engine = session.get_bind() 171 | if engine.dialect.name == "mysql": 172 | return select(Schedule).where( 173 | Schedule.target == cast(model_dump(target), JSON) 174 | ) 175 | return select(Schedule).where(Schedule.target == model_dump(target)) 176 | 177 | 178 | schedule_service = Scheduler() 179 | -------------------------------------------------------------------------------- /nonebot_plugin_wordcloud/utils.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from datetime import datetime, time, tzinfo 3 | from typing import Optional 4 | from zoneinfo import ZoneInfo 5 | 6 | from nonebot.compat import model_dump 7 | from nonebot.matcher import Matcher 8 | from nonebot.params import Depends 9 | from nonebot.permission import SUPERUSER 10 | from nonebot_plugin_apscheduler import scheduler 11 | from nonebot_plugin_saa import PlatformTarget, get_target 12 | from nonebot_plugin_uninfo import SceneType, Session, UniSession 13 | 14 | from .config import plugin_config 15 | 16 | 17 | def get_datetime_now_with_timezone() -> datetime: 18 | """获取当前时间,并包含时区信息""" 19 | if plugin_config.wordcloud_timezone: 20 | return datetime.now(ZoneInfo(plugin_config.wordcloud_timezone)) 21 | else: 22 | return datetime.now().astimezone() 23 | 24 | 25 | def get_datetime_fromisoformat_with_timezone(date_string: str) -> datetime: 26 | """从 ISO-8601 格式字符串中获取时间,并包含时区信息""" 27 | if not plugin_config.wordcloud_timezone: 28 | return datetime.fromisoformat(date_string).astimezone() 29 | raw = datetime.fromisoformat(date_string) 30 | return ( 31 | raw.astimezone(ZoneInfo(plugin_config.wordcloud_timezone)) 32 | if raw.tzinfo 33 | else raw.replace(tzinfo=ZoneInfo(plugin_config.wordcloud_timezone)) 34 | ) 35 | 36 | 37 | def time_astimezone(time: time, tz: Optional[tzinfo] = None) -> time: 38 | """将 time 对象转换为指定时区的 time 对象 39 | 40 | 如果 tz 为 None,则转换为本地时区 41 | """ 42 | local_time = datetime.combine(datetime.today(), time) 43 | return local_time.astimezone(tz).timetz() 44 | 45 | 46 | def get_time_fromisoformat_with_timezone(time_string: str) -> time: 47 | """从 iso8601 格式字符串中获取时间,并包含时区信息""" 48 | if not plugin_config.wordcloud_timezone: 49 | return time_astimezone(time.fromisoformat(time_string)) 50 | raw = time.fromisoformat(time_string) 51 | return ( 52 | time_astimezone(raw, ZoneInfo(plugin_config.wordcloud_timezone)) 53 | if raw.tzinfo 54 | else raw.replace(tzinfo=ZoneInfo(plugin_config.wordcloud_timezone)) 55 | ) 56 | 57 | 58 | def get_time_with_scheduler_timezone(time: time) -> time: 59 | """获取转换到 APScheduler 时区的时间""" 60 | return time_astimezone(time, scheduler.timezone) 61 | 62 | 63 | def admin_permission(): 64 | permission = SUPERUSER 65 | with contextlib.suppress(ImportError): 66 | from nonebot.adapters.onebot.v11.permission import GROUP_ADMIN, GROUP_OWNER 67 | 68 | permission = permission | GROUP_ADMIN | GROUP_OWNER 69 | 70 | return permission 71 | 72 | 73 | def get_mask_key(target: PlatformTarget = Depends(get_target)) -> str: 74 | """获取 mask key 75 | 76 | 例如: 77 | qq_group-group_id=10000 78 | qq_guild_channel-channel_id=100000 79 | """ 80 | mask_keys = [f"{target.platform_type.name}"] 81 | mask_keys.extend( 82 | [ 83 | f"{key}={value}" 84 | for key, value in model_dump(target, exclude={"platform_type"}).items() 85 | if value is not None 86 | ] 87 | ) 88 | return "-".join(mask_keys) 89 | 90 | 91 | async def ensure_group(matcher: Matcher, session: Session = UniSession()): 92 | """确保在群组中使用""" 93 | if session.scene.type not in [ 94 | SceneType.GROUP, 95 | SceneType.GUILD, 96 | SceneType.CHANNEL_TEXT, 97 | ]: 98 | await matcher.finish("请在群组中使用!") 99 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "nonebot-plugin-wordcloud" 3 | version = "0.9.0" 4 | description = "适用于 NoneBot2 的词云插件" 5 | authors = [{ name = "uy_sun", email = "hmy0119@gmail.com" }] 6 | readme = "README.md" 7 | license = { file = "LICENSE" } 8 | requires-python = ">= 3.9" 9 | dependencies = [ 10 | "nonebot2[fastapi]>=2.2.0", 11 | "nonebot-plugin-apscheduler>=0.4.0", 12 | "nonebot-plugin-localstore>=0.6.0", 13 | "nonebot-plugin-orm>=0.7.0", 14 | "nonebot-plugin-alconna>=0.49.0", 15 | "nonebot-plugin-uninfo>=0.6.2", 16 | "nonebot-plugin-cesaa>=0.5.0,<0.6.0", 17 | "wordcloud>=1.8.1", 18 | "jieba>=0.42.1", 19 | "tzdata", 20 | "emoji>=1.6.3", 21 | ] 22 | 23 | [project.urls] 24 | Homepage = "https://github.com/he0119/nonebot-plugin-wordcloud" 25 | Repository = "https://github.com/he0119/nonebot-plugin-wordcloud.git" 26 | Issues = "https://github.com/he0119/nonebot-plugin-wordcloud/issues" 27 | Changelog = "https://github.com/he0119/nonebot-plugin-wordcloud/blob/main/CHANGELOG.md" 28 | 29 | [project.optional-dependencies] 30 | datastore = ["nonebot-plugin-datastore>=1.2.0"] 31 | 32 | [build-system] 33 | requires = ["hatchling"] 34 | build-backend = "hatchling.build" 35 | 36 | [tool.hatch.metadata] 37 | allow-direct-references = true 38 | 39 | [tool.hatch.build.targets.wheel] 40 | packages = ["nonebot_plugin_wordcloud"] 41 | 42 | [tool.hatch.build.targets.sdist] 43 | only-include = ["nonebot_plugin_wordcloud"] 44 | 45 | [tool.uv] 46 | dev-dependencies = [ 47 | "nb-cli>=1.4.1", 48 | "nonebug>=0.4.3", 49 | "nonebug-saa>=0.5.0", 50 | "nonebot-adapter-onebot>=2.4.4", 51 | "nonebot-plugin-orm[default]>=0.7.4", 52 | "nonebot-plugin-datastore>=1.3.0", 53 | "asyncpg>=0.29.0", 54 | "aiomysql>=0.2.0", 55 | "pytest-cov>=5.0.0", 56 | "pytest-xdist>=3.6.1", 57 | "pytest-mock>=3.14.0", 58 | "pytest-asyncio>=0.23.7", 59 | "respx>=0.21.1", 60 | "httpx>=0.27.0,<0.28.0", 61 | "gevent>=24.2.1", 62 | "cryptography>=42.0.8", 63 | "bump-my-version>=0.25.4", 64 | "poethepoet>=0.31.1", 65 | ] 66 | 67 | [tool.uv.pip] 68 | universal = true 69 | 70 | [tool.poe.tasks] 71 | test = "pytest --cov=nonebot_plugin_wordcloud --cov-report xml --junitxml=./junit.xml -n auto" 72 | "test:single" = "pytest --cov=nonebot_plugin_wordcloud --cov-report xml --junitxml=./junit.xml" 73 | # async sqlalchemy 需要使用 gevent 才能获得正确覆盖率 74 | # https://github.com/nedbat/coveragepy/issues/1082 75 | "test:gevent" = "coverage run --concurrency=thread,gevent -m pytest --cov=nonebot_plugin_wordcloud --cov-report xml --junitxml=./junit.xml -n auto" 76 | # Python 3.12 下需要启用 sysmon 模式,否则测试速度非常慢 77 | # https://github.com/nedbat/coveragepy/issues/1665 78 | "test:sysmon".ref = "test" 79 | "test:sysmon".env = { "COVERAGE_CORE" = "sysmon" } 80 | bump = "bump-my-version bump" 81 | show-bump = "bump-my-version show-bump" 82 | 83 | [tool.pyright] 84 | pythonVersion = "3.9" 85 | pythonPlatform = "All" 86 | typeCheckingMode = "standard" 87 | defineConstant = { PYDANTIC_V2 = true } 88 | 89 | [tool.ruff] 90 | line-length = 88 91 | target-version = "py39" 92 | 93 | [tool.ruff.lint] 94 | select = [ 95 | "W", # pycodestyle warnings 96 | "E", # pycodestyle errors 97 | "F", # pyflakes 98 | "UP", # pyupgrade 99 | "C4", # flake8-comprehensions 100 | "T10", # flake8-debugger 101 | "T20", # flake8-print 102 | "PYI", # flake8-pyi 103 | "PT", # flake8-pytest-style 104 | "Q", # flake8-quotes 105 | "TC", # flake8-type-checking 106 | "RUF", # Ruff-specific rules 107 | "I", # isort 108 | ] 109 | ignore = [ 110 | "E402", # module-import-not-at-top-of-file 111 | "RUF001", # ambiguous-unicode-character-string 112 | "RUF002", # ambiguous-unicode-character-docstring 113 | "RUF003", # ambiguous-unicode-character-comment 114 | ] 115 | 116 | [tool.nonebot] 117 | plugins = ["nonebot_plugin_wordcloud"] 118 | adapters = [ 119 | { name = "OneBot V12", module_name = "nonebot.adapters.onebot.v12", project_link = "nonebot-adapter-onebot", desc = "OneBot V12 协议" }, 120 | { name = "OneBot V11", module_name = "nonebot.adapters.onebot.v11", project_link = "nonebot-adapter-onebot", desc = "OneBot V11 协议" }, 121 | ] 122 | 123 | [tool.coverage.report] 124 | exclude_lines = [ 125 | "pragma: no cover", 126 | "raise NotImplementedError", 127 | "if __name__ == .__main__.:", 128 | "if TYPE_CHECKING:", 129 | "@overload", 130 | "except ImportError:", 131 | ] 132 | omit = ["*/compat.py", "*/migrations/*"] 133 | 134 | [tool.pytest.ini_options] 135 | addopts = ["--import-mode=importlib"] 136 | asyncio_mode = "auto" 137 | asyncio_default_fixture_loop_scope = "session" 138 | 139 | [tool.bumpversion] 140 | current_version = "0.9.0" 141 | commit = true 142 | message = "chore(release): {new_version}" 143 | 144 | [[tool.bumpversion.files]] 145 | filename = "pyproject.toml" 146 | search = "version = \"{current_version}\"" 147 | replace = "version = \"{new_version}\"" 148 | 149 | [[tool.bumpversion.files]] 150 | filename = "CHANGELOG.md" 151 | search = "## [Unreleased]" 152 | replace = "## [Unreleased]\n\n## [{new_version}] - {now:%Y-%m-%d}" 153 | 154 | [[tool.bumpversion.files]] 155 | filename = "CHANGELOG.md" 156 | regex = true 157 | search = "\\[Unreleased\\]: (https://.+?)v{current_version}\\.\\.\\.HEAD" 158 | replace = "[Unreleased]: \\1v{new_version}...HEAD\n[{new_version}]: \\1v{current_version}...v{new_version}" 159 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/he0119/nonebot-plugin-wordcloud/ef14367c14c6a50cb171834f577f4e9127d31bc6/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import nonebot 4 | import pytest 5 | from nonebot.adapters.onebot.v11 import Adapter as OnebotV11Adapter 6 | from nonebot.adapters.onebot.v12 import Adapter as OnebotV12Adapter 7 | from nonebug import NONEBOT_INIT_KWARGS, NONEBOT_START_LIFESPAN, App 8 | from pytest_asyncio import is_async_test 9 | from pytest_mock import MockerFixture 10 | from sqlalchemy import StaticPool, delete 11 | 12 | 13 | def pytest_configure(config: pytest.Config) -> None: 14 | config.stash[NONEBOT_INIT_KWARGS] = { 15 | "sqlalchemy_database_url": "sqlite+aiosqlite://", 16 | "sqlalchemy_engine_options": {"poolclass": StaticPool}, 17 | "driver": "~fastapi+~httpx", 18 | "alembic_startup_check": False, 19 | "command_start": {"/", ""}, 20 | } 21 | config.stash[NONEBOT_START_LIFESPAN] = False 22 | 23 | 24 | def pytest_collection_modifyitems(items: list[pytest.Item]): 25 | pytest_asyncio_tests = (item for item in items if is_async_test(item)) 26 | session_scope_marker = pytest.mark.asyncio(loop_scope="session") 27 | for async_test in pytest_asyncio_tests: 28 | async_test.add_marker(session_scope_marker, append=False) 29 | 30 | 31 | @pytest.fixture(scope="session", autouse=True) 32 | async def after_nonebot_init(after_nonebot_init: None): 33 | # 加载适配器 34 | driver = nonebot.get_driver() 35 | driver.register_adapter(OnebotV11Adapter) 36 | driver.register_adapter(OnebotV12Adapter) 37 | 38 | # 手动启动生命周期 39 | # 在加载 orm 之前运行,避免 orm 因未 mock 数据目录导致并发时出错 40 | await driver._lifespan.startup() 41 | 42 | # 加载插件 43 | nonebot.load_plugin("nonebot_plugin_wordcloud") 44 | 45 | # 手动缓存 uninfo 所需信息 46 | from nonebot_plugin_uninfo import ( 47 | Scene, 48 | SceneType, 49 | Session, 50 | SupportAdapter, 51 | SupportScope, 52 | User, 53 | ) 54 | from nonebot_plugin_uninfo.adapters.onebot11.main import fetcher as onebot11_fetcher 55 | from nonebot_plugin_uninfo.adapters.onebot12.main import fetcher as onebot12_fetcher 56 | 57 | onebot11_fetcher.session_cache = { 58 | "group_10000_10": Session( 59 | self_id="test", 60 | adapter=SupportAdapter.onebot11, 61 | scope=SupportScope.qq_client, 62 | scene=Scene("10000", SceneType.GROUP), 63 | user=User("10"), 64 | ) 65 | } 66 | onebot12_fetcher.session_cache = { 67 | "group_10000_100": Session( 68 | self_id="test", 69 | adapter=SupportAdapter.onebot12, 70 | scope=SupportScope.qq_client, 71 | scene=Scene("10000", SceneType.GROUP), 72 | user=User("100"), 73 | ), 74 | "guild_10000_channel_100000_10": Session( 75 | self_id="test", 76 | adapter=SupportAdapter.onebot12, 77 | scope=SupportScope.qq_guild, 78 | scene=Scene( 79 | "100000", SceneType.CHANNEL_TEXT, parent=Scene("10000", SceneType.GUILD) 80 | ), 81 | user=User("10"), 82 | ), 83 | } 84 | 85 | 86 | @pytest.fixture 87 | async def app(app: App, tmp_path: Path, mocker: MockerFixture): 88 | wordcloud_dir = tmp_path / "wordcloud" 89 | wordcloud_dir.mkdir() 90 | mocker.patch("nonebot_plugin_wordcloud.config.DATA_DIR", wordcloud_dir) 91 | mocker.patch("nonebot_plugin_orm._data_dir", tmp_path / "orm") 92 | from nonebot_plugin_orm import get_session, init_orm 93 | 94 | from nonebot_plugin_wordcloud.schedule import schedule_service 95 | 96 | await init_orm() 97 | yield app 98 | 99 | from nonebot_plugin_chatrecorder.model import MessageRecord 100 | from nonebot_plugin_uninfo.orm import SessionModel 101 | 102 | from nonebot_plugin_wordcloud.model import Schedule 103 | 104 | # 清理数据 105 | async with get_session() as session, session.begin(): 106 | await session.execute(delete(MessageRecord)) 107 | await session.execute(delete(Schedule)) 108 | await session.execute(delete(SessionModel)) 109 | 110 | keys = [key for key in schedule_service.schedules.keys() if key != "default"] 111 | for key in keys: 112 | schedule_service.schedules.pop(key) 113 | -------------------------------------------------------------------------------- /tests/mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/he0119/nonebot-plugin-wordcloud/ef14367c14c6a50cb171834f577f4e9127d31bc6/tests/mask.png -------------------------------------------------------------------------------- /tests/test_colormap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/he0119/nonebot-plugin-wordcloud/ef14367c14c6a50cb171834f577f4e9127d31bc6/tests/test_colormap.png -------------------------------------------------------------------------------- /tests/test_colormap.py: -------------------------------------------------------------------------------- 1 | import random 2 | from io import BytesIO 3 | from pathlib import Path 4 | 5 | from nonebug import App 6 | from PIL import Image, ImageChops 7 | from pytest_mock import MockerFixture 8 | 9 | 10 | async def test_colormap(app: App, mocker: MockerFixture): 11 | """测试设置色彩映射表""" 12 | 13 | from nonebot_plugin_wordcloud.config import plugin_config 14 | from nonebot_plugin_wordcloud.data_source import get_wordcloud 15 | 16 | mocker.patch.object(plugin_config, "wordcloud_colormap", "Pastel1") 17 | 18 | mocked_random = mocker.patch("wordcloud.wordcloud.Random") 19 | mocked_random.return_value = random.Random(0) 20 | 21 | image_byte = await get_wordcloud(["天气"], "") 22 | 23 | assert image_byte is not None 24 | 25 | # 比较生成的图片是否相同 26 | test_image_path = Path(__file__).parent / "test_colormap.png" 27 | test_image = Image.open(test_image_path) 28 | image = Image.open(BytesIO(image_byte)) 29 | diff = ImageChops.difference(image, test_image) 30 | assert diff.getbbox() is None 31 | 32 | mocked_random.assert_called_once_with() 33 | 34 | 35 | async def test_colormap_by_options(app: App, mocker: MockerFixture): 36 | """测试通过 options 设置色彩映射表""" 37 | from nonebot_plugin_wordcloud.config import plugin_config 38 | from nonebot_plugin_wordcloud.data_source import get_wordcloud 39 | 40 | mocker.patch.object(plugin_config, "wordcloud_options", {"colormap": "Pastel1"}) 41 | 42 | mocked_random = mocker.patch("wordcloud.wordcloud.Random") 43 | mocked_random.return_value = random.Random(0) 44 | 45 | image_byte = await get_wordcloud(["天气"], "") 46 | 47 | assert image_byte is not None 48 | 49 | # 比较生成的图片是否相同 50 | test_image_path = Path(__file__).parent / "test_colormap.png" 51 | test_image = Image.open(test_image_path) 52 | image = Image.open(BytesIO(image_byte)) 53 | diff = ImageChops.difference(image, test_image) 54 | assert diff.getbbox() is None 55 | 56 | mocked_random.assert_called_once_with() 57 | 58 | 59 | async def test_colormap_random(app: App, mocker: MockerFixture): 60 | """测试随机选择色彩映射表""" 61 | from nonebot_plugin_wordcloud.config import plugin_config 62 | from nonebot_plugin_wordcloud.data_source import get_wordcloud 63 | 64 | mocker.patch.object(plugin_config, "wordcloud_colormap", ["Pastel1", "plasma"]) 65 | 66 | mocked_choice = mocker.patch("nonebot_plugin_wordcloud.data_source.choice") 67 | mocked_choice.return_value = "Pastel1" 68 | 69 | mocked_random = mocker.patch("wordcloud.wordcloud.Random") 70 | mocked_random.return_value = random.Random(0) 71 | 72 | image_byte = await get_wordcloud(["天气"], "") 73 | 74 | assert image_byte is not None 75 | 76 | # 比较生成的图片是否相同 77 | test_image_path = Path(__file__).parent / "test_colormap.png" 78 | test_image = Image.open(test_image_path) 79 | image = Image.open(BytesIO(image_byte)) 80 | diff = ImageChops.difference(image, test_image) 81 | assert diff.getbbox() is None 82 | 83 | mocked_choice.assert_called_once_with(["Pastel1", "plasma"]) 84 | mocked_random.assert_called_once_with() 85 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from zoneinfo import ZoneInfo 2 | 3 | import pytest 4 | from nonebot.compat import type_validate_python 5 | from nonebug import App 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "default_config", 10 | [ 11 | pytest.param( 12 | {"wordcloud_default_schedule_time": "20:00"}, id="without_timezone" 13 | ), 14 | pytest.param( 15 | {"wordcloud_default_schedule_time": "20:00:00+08:00"}, id="with_timezone" 16 | ), 17 | ], 18 | ) 19 | async def test_default_schedule_time(app: App, default_config: dict[str, str]): 20 | """测试设置默认定时发送时间""" 21 | from nonebot_plugin_wordcloud.config import Config 22 | 23 | config = type_validate_python(Config, default_config) 24 | 25 | default_time = config.wordcloud_default_schedule_time 26 | assert default_time.isoformat() == "20:00:00+08:00" 27 | 28 | 29 | @pytest.mark.parametrize( 30 | "default_config", 31 | [ 32 | pytest.param( 33 | { 34 | "wordcloud_default_schedule_time": "20:00", 35 | "wordcloud_timezone": "Asia/Tokyo", 36 | }, 37 | id="without_timezone", 38 | ), 39 | pytest.param( 40 | { 41 | "wordcloud_default_schedule_time": "20:00:00+09:00", 42 | "wordcloud_timezone": "Asia/Tokyo", 43 | }, 44 | id="with_timezone", 45 | ), 46 | ], 47 | ) 48 | async def test_default_schedule_time_with_timezone( 49 | app: App, default_config: dict[str, str] 50 | ): 51 | """测试设置默认定时发送时间,同时设置时区""" 52 | from nonebot_plugin_wordcloud.config import Config 53 | 54 | config = type_validate_python(Config, default_config) 55 | 56 | default_time = config.wordcloud_default_schedule_time 57 | assert default_time.isoformat() == "20:00:00" 58 | assert default_time.tzinfo == ZoneInfo("Asia/Tokyo") 59 | -------------------------------------------------------------------------------- /tests/test_masked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/he0119/nonebot-plugin-wordcloud/ef14367c14c6a50cb171834f577f4e9127d31bc6/tests/test_masked.png -------------------------------------------------------------------------------- /tests/test_masked.py: -------------------------------------------------------------------------------- 1 | import random 2 | import shutil 3 | from io import BytesIO 4 | from pathlib import Path 5 | 6 | import respx 7 | from httpx import Response 8 | from nonebot import get_adapter, get_driver 9 | from nonebot.adapters.onebot.v11 import Adapter, Bot, Message, MessageSegment 10 | from nonebug import App 11 | from nonebug_saa import should_send_saa 12 | from PIL import Image, ImageChops 13 | from pytest_mock import MockerFixture 14 | 15 | from .utils import fake_group_message_event_v11, fake_private_message_event_v11 16 | 17 | 18 | async def test_masked(app: App, mocker: MockerFixture): 19 | """测试自定义图片形状""" 20 | from nonebot_plugin_wordcloud.config import DATA_DIR, plugin_config 21 | from nonebot_plugin_wordcloud.data_source import get_wordcloud 22 | 23 | mocker.patch.object(plugin_config, "wordcloud_background_color", "white") 24 | 25 | mask_path = Path(__file__).parent / "mask.png" 26 | shutil.copy(mask_path, DATA_DIR / "mask.png") 27 | 28 | mocked_random = mocker.patch("wordcloud.wordcloud.Random") 29 | mocked_random.return_value = random.Random(0) 30 | 31 | image_byte = await get_wordcloud(["示例", "插件", "测试"], "") 32 | 33 | assert image_byte is not None 34 | 35 | # 比较生成的图片是否相同 36 | test_image_path = Path(__file__).parent / "test_masked.png" 37 | test_image = Image.open(test_image_path) 38 | image = Image.open(BytesIO(image_byte)) 39 | diff = ImageChops.difference(image, test_image) 40 | assert diff.getbbox() is None 41 | 42 | mocked_random.assert_called() 43 | 44 | 45 | async def test_masked_by_command(app: App, mocker: MockerFixture): 46 | """测试自定义图片形状""" 47 | from nonebot_plugin_saa import Image, MessageFactory 48 | 49 | from nonebot_plugin_wordcloud import wordcloud_cmd 50 | from nonebot_plugin_wordcloud.config import DATA_DIR, plugin_config 51 | 52 | mocker.patch.object(plugin_config, "wordcloud_background_color", "white") 53 | 54 | mask_path = Path(__file__).parent / "mask.png" 55 | shutil.copy(mask_path, DATA_DIR / "mask.png") 56 | 57 | mocked_random = mocker.patch("wordcloud.wordcloud.Random") 58 | mocked_random.return_value = random.Random(0) 59 | 60 | mocked_get_messages_plain_text = mocker.patch( 61 | "nonebot_plugin_wordcloud.get_messages_plain_text", 62 | return_value=["示例", "插件", "测试"], 63 | ) 64 | 65 | test_image_path = Path(__file__).parent / "test_masked.png" 66 | with test_image_path.open("rb") as f: 67 | test_image = f.read() 68 | 69 | async with app.test_matcher(wordcloud_cmd) as ctx: 70 | adapter = get_adapter(Adapter) 71 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 72 | event = fake_group_message_event_v11(message=Message("/今日词云")) 73 | 74 | ctx.receive_event(bot, event) 75 | should_send_saa( 76 | ctx, 77 | MessageFactory(Image(test_image, "wordcloud.png")), 78 | bot, 79 | event=event, 80 | ) 81 | ctx.should_finished(wordcloud_cmd) 82 | 83 | mocked_random.assert_called() 84 | mocked_get_messages_plain_text.assert_called_once() 85 | 86 | 87 | async def test_masked_group(app: App, mocker: MockerFixture): 88 | """测试不同群的自定义图片形状""" 89 | from nonebot_plugin_wordcloud.config import DATA_DIR, plugin_config 90 | from nonebot_plugin_wordcloud.data_source import get_wordcloud 91 | 92 | mocker.patch.object(plugin_config, "wordcloud_background_color", "white") 93 | 94 | mask_path = Path(__file__).parent / "mask.png" 95 | shutil.copy(mask_path, DATA_DIR / "mask-10000.png") 96 | 97 | mocked_random = mocker.patch("wordcloud.wordcloud.Random") 98 | mocked_random.return_value = random.Random(0) 99 | 100 | image_byte = await get_wordcloud(["示例", "插件", "测试"], "10000") 101 | 102 | assert image_byte is not None 103 | 104 | # 比较生成的图片是否相同 105 | test_image_path = Path(__file__).parent / "test_masked.png" 106 | test_image = Image.open(test_image_path) 107 | image = Image.open(BytesIO(image_byte)) 108 | diff = ImageChops.difference(image, test_image) 109 | assert diff.getbbox() is None 110 | 111 | mocked_random.assert_called() 112 | 113 | 114 | @respx.mock(assert_all_called=True) 115 | async def test_set_mask_default( 116 | app: App, mocker: MockerFixture, respx_mock: respx.MockRouter 117 | ): 118 | """测试自定义图片形状""" 119 | from nonebot_plugin_wordcloud import set_mask_cmd 120 | from nonebot_plugin_wordcloud.config import DATA_DIR 121 | 122 | mask_path = Path(__file__).parent / "mask.png" 123 | with mask_path.open("rb") as f: 124 | mask_image = f.read() 125 | 126 | image_url = respx_mock.get("https://test").mock( 127 | return_value=Response(200, content=mask_image) 128 | ) 129 | 130 | config = get_driver().config 131 | 132 | async with app.test_matcher(set_mask_cmd) as ctx: 133 | adapter = get_adapter(Adapter) 134 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 135 | message = Message("/设置词云默认形状") + MessageSegment( 136 | "image", {"url": "https://test", "file": ""} 137 | ) 138 | event = fake_group_message_event_v11(message=message, sender={"role": "owner"}) 139 | 140 | ctx.receive_event(bot, event) 141 | ctx.should_call_send(event, "仅超级用户可设置词云默认形状", True) 142 | ctx.should_finished() 143 | 144 | assert image_url.call_count == 1 145 | 146 | mocker.patch.object(config, "superusers", {"10"}) 147 | 148 | async with app.test_matcher(set_mask_cmd) as ctx: 149 | adapter = get_adapter(Adapter) 150 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 151 | message = Message("/设置词云默认形状") + MessageSegment( 152 | "image", {"url": "https://test", "file": ""} 153 | ) 154 | event = fake_group_message_event_v11(message=message, sender={"role": "owner"}) 155 | 156 | ctx.receive_event(bot, event) 157 | ctx.should_call_send(event, "词云默认形状设置成功", True) 158 | ctx.should_finished() 159 | 160 | assert (DATA_DIR / "mask.png").exists() 161 | assert image_url.call_count == 2 162 | 163 | 164 | @respx.mock(assert_all_called=True) 165 | async def test_set_mask(app: App, respx_mock: respx.MockRouter): 166 | """测试自定义图片形状""" 167 | from nonebot_plugin_wordcloud import set_mask_cmd 168 | from nonebot_plugin_wordcloud.config import DATA_DIR 169 | 170 | image_url = respx_mock.get("https://test").mock( 171 | return_value=Response( 172 | 200, content=(Path(__file__).parent / "mask.png").read_bytes() 173 | ) 174 | ) 175 | 176 | assert not (DATA_DIR / "mask-qq_group-group_id=10000.png").exists() 177 | 178 | async with app.test_matcher(set_mask_cmd) as ctx: 179 | adapter = get_adapter(Adapter) 180 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 181 | message = Message("/设置词云形状") + MessageSegment( 182 | "image", {"url": "https://test", "file": ""} 183 | ) 184 | event = fake_group_message_event_v11(message=message, sender={"role": "owner"}) 185 | 186 | ctx.receive_event(bot, event) 187 | ctx.should_call_send(event, "词云形状设置成功", True) 188 | ctx.should_finished() 189 | 190 | assert image_url.call_count == 1 191 | assert (DATA_DIR / "mask-qq_group-group_id=10000.png").exists() 192 | 193 | 194 | @respx.mock(assert_all_called=True) 195 | async def test_set_mask_get_args(app: App, respx_mock: respx.MockRouter): 196 | """测试自定义图片形状,需要额外获取图片时的情况""" 197 | from nonebot_plugin_wordcloud import set_mask_cmd 198 | from nonebot_plugin_wordcloud.config import DATA_DIR 199 | 200 | image_url = respx_mock.get("https://test").mock( 201 | return_value=Response( 202 | 200, content=(Path(__file__).parent / "mask.png").read_bytes() 203 | ) 204 | ) 205 | 206 | async with app.test_matcher(set_mask_cmd) as ctx: 207 | adapter = get_adapter(Adapter) 208 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 209 | message = Message("/设置词云形状") 210 | event = fake_group_message_event_v11(message=message, sender={"role": "owner"}) 211 | 212 | ctx.receive_event(bot, event) 213 | ctx.should_call_send(event, "请发送一张图片作为词云形状", True) 214 | ctx.should_rejected() 215 | 216 | invalid_message = Message(MessageSegment.text("test")) 217 | invalid_event = fake_group_message_event_v11( 218 | message=invalid_message, sender={"role": "owner"} 219 | ) 220 | ctx.receive_event(bot, invalid_event) 221 | ctx.should_call_send(invalid_event, "请发送一张图片作为词云形状", True) 222 | ctx.should_rejected() 223 | 224 | image_message = Message( 225 | MessageSegment("image", {"url": "https://test", "file": ""}) 226 | ) 227 | image_event = fake_group_message_event_v11( 228 | message=image_message, sender={"role": "owner"} 229 | ) 230 | ctx.receive_event(bot, image_event) 231 | ctx.should_call_send(image_event, "词云形状设置成功", True) 232 | ctx.should_finished() 233 | 234 | assert (DATA_DIR / "mask-qq_group-group_id=10000.png").exists() 235 | assert image_url.call_count == 1 236 | 237 | 238 | async def test_remove_default_mask(app: App, mocker: MockerFixture): 239 | """移除默认形状""" 240 | from nonebot_plugin_wordcloud import remove_mask_cmd 241 | from nonebot_plugin_wordcloud.config import DATA_DIR 242 | 243 | mask_path = Path(__file__).parent / "mask.png" 244 | 245 | mask_default_path = DATA_DIR / "mask.png" 246 | mask_group_path = DATA_DIR / "mask-qq_group-group_id=10000.png" 247 | 248 | shutil.copy(mask_path, mask_default_path) 249 | shutil.copy(mask_path, mask_group_path) 250 | 251 | assert mask_default_path.exists() 252 | assert mask_group_path.exists() 253 | 254 | async with app.test_matcher(remove_mask_cmd) as ctx: 255 | adapter = get_adapter(Adapter) 256 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 257 | message = Message("/删除词云默认形状") 258 | event = fake_group_message_event_v11(message=message, sender={"role": "owner"}) 259 | 260 | ctx.receive_event(bot, event) 261 | ctx.should_call_send(event, "仅超级用户可删除词云默认形状", True) 262 | ctx.should_finished() 263 | 264 | mocker.patch.object(get_driver().config, "superusers", {"10"}) 265 | 266 | async with app.test_matcher(remove_mask_cmd) as ctx: 267 | adapter = get_adapter(Adapter) 268 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 269 | message = Message("/删除词云默认形状") 270 | event = fake_group_message_event_v11(message=message, sender={"role": "owner"}) 271 | 272 | ctx.receive_event(bot, event) 273 | ctx.should_call_send(event, "词云默认形状已删除", True) 274 | ctx.should_finished() 275 | 276 | assert not mask_default_path.exists() 277 | assert mask_group_path.exists() 278 | 279 | 280 | async def test_remove_mask(app: App): 281 | from nonebot_plugin_wordcloud import remove_mask_cmd 282 | from nonebot_plugin_wordcloud.config import DATA_DIR 283 | 284 | mask_path = Path(__file__).parent / "mask.png" 285 | 286 | mask_default_path = DATA_DIR / "mask.png" 287 | mask_group_path = DATA_DIR / "mask-qq_group-group_id=10000.png" 288 | 289 | shutil.copy(mask_path, mask_default_path) 290 | shutil.copy(mask_path, mask_group_path) 291 | 292 | assert mask_default_path.exists() 293 | assert mask_group_path.exists() 294 | 295 | async with app.test_matcher(remove_mask_cmd) as ctx: 296 | adapter = get_adapter(Adapter) 297 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 298 | message = Message("/删除词云形状") 299 | event = fake_group_message_event_v11(message=message, sender={"role": "owner"}) 300 | 301 | ctx.receive_event(bot, event) 302 | ctx.should_call_send(event, "词云形状已删除", True) 303 | ctx.should_finished() 304 | 305 | assert mask_default_path.exists() 306 | assert not mask_group_path.exists() 307 | 308 | 309 | async def test_set_mask_private(app: App, mocker: MockerFixture): 310 | """测试私聊设置词云形状""" 311 | from nonebot_plugin_wordcloud import set_mask_cmd 312 | 313 | config = get_driver().config 314 | 315 | mocker.patch.object(config, "superusers", {"10"}) 316 | 317 | async with app.test_matcher(set_mask_cmd) as ctx: 318 | adapter = get_adapter(Adapter) 319 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 320 | event = fake_private_message_event_v11(message=Message("/设置词云形状")) 321 | 322 | ctx.receive_event(bot, event) 323 | ctx.should_call_send( 324 | event, 325 | "请在群组中使用!", 326 | True, 327 | ) 328 | ctx.should_finished() 329 | 330 | 331 | async def test_remove_mask_private(app: App, mocker: MockerFixture): 332 | """测试私聊删除词云形状""" 333 | from nonebot_plugin_wordcloud import remove_mask_cmd 334 | 335 | config = get_driver().config 336 | 337 | mocker.patch.object(config, "superusers", {"10"}) 338 | 339 | async with app.test_matcher(remove_mask_cmd) as ctx: 340 | adapter = get_adapter(Adapter) 341 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 342 | event = fake_private_message_event_v11(message=Message("/删除词云形状")) 343 | 344 | ctx.receive_event(bot, event) 345 | ctx.should_call_send( 346 | event, 347 | "请在群组中使用!", 348 | True, 349 | ) 350 | ctx.should_finished() 351 | -------------------------------------------------------------------------------- /tests/test_pre_process.py: -------------------------------------------------------------------------------- 1 | from nonebug import App 2 | 3 | 4 | async def test_remove_emoji(app: App): 5 | """测试移除 emoji""" 6 | 7 | from nonebot_plugin_wordcloud.data_source import pre_precess 8 | 9 | msg = "1😅🟨二" 10 | msg = pre_precess(msg) 11 | assert msg == "1二" 12 | 13 | 14 | async def test_remove_http(app: App): 15 | """测试移除网址""" 16 | 17 | from nonebot_plugin_wordcloud.data_source import pre_precess 18 | 19 | msg = "1 http://v2.nonebot.dev/ 2" 20 | msg = pre_precess(msg) 21 | assert msg == "1 2" 22 | 23 | msg = "1 https://v2.nonebot.dev/ 2" 24 | msg = pre_precess(msg) 25 | assert msg == "1 2" 26 | 27 | msg = "1 https://api.weibo.cn/share/312975272,470873388.html?weibo_id=4770873388 2" 28 | msg = pre_precess(msg) 29 | assert msg == "1 2" 30 | -------------------------------------------------------------------------------- /tests/test_schedule.py: -------------------------------------------------------------------------------- 1 | from datetime import time 2 | from io import BytesIO 3 | 4 | from nonebot import get_adapter, get_driver 5 | from nonebot.adapters.onebot.v11 import Adapter, Bot, Message 6 | from nonebot.adapters.onebot.v12 import Adapter as AdapterV12 7 | from nonebot.adapters.onebot.v12 import Bot as BotV12 8 | from nonebot.adapters.onebot.v12 import Message as MessageV12 9 | from nonebug import App 10 | from nonebug_saa import should_send_saa 11 | from pytest_mock import MockerFixture 12 | from sqlalchemy import select 13 | 14 | from .utils import ( 15 | fake_channel_message_event_v12, 16 | fake_group_message_event_v11, 17 | fake_group_message_event_v12, 18 | fake_private_message_event_v11, 19 | ) 20 | 21 | 22 | async def test_enable_schedule(app: App): 23 | from nonebot_plugin_wordcloud import schedule_cmd, schedule_service 24 | 25 | async with app.test_matcher(schedule_cmd) as ctx: 26 | adapter = get_adapter(Adapter) 27 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 28 | event = fake_group_message_event_v11( 29 | message=Message("/开启词云每日定时发送"), sender={"role": "admin"} 30 | ) 31 | ctx.receive_event(bot, event) 32 | ctx.should_pass_permission(schedule_cmd) 33 | ctx.should_call_send( 34 | event, "已开启词云每日定时发送,发送时间为:22:00:00+08:00", True 35 | ) 36 | ctx.should_finished(schedule_cmd) 37 | 38 | assert len(schedule_service.schedules) == 1 39 | 40 | async with app.test_matcher(schedule_cmd) as ctx: 41 | adapter = get_adapter(Adapter) 42 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 43 | event = fake_group_message_event_v11( 44 | message=Message("/开启词云每日定时发送 10:00"), sender={"role": "admin"} 45 | ) 46 | 47 | ctx.receive_event(bot, event) 48 | ctx.should_pass_permission(schedule_cmd) 49 | ctx.should_call_send( 50 | event, "已开启词云每日定时发送,发送时间为:10:00:00+08:00", True 51 | ) 52 | ctx.should_finished(schedule_cmd) 53 | 54 | assert len(schedule_service.schedules) == 2 55 | 56 | async with app.test_matcher(schedule_cmd) as ctx: 57 | adapter = get_adapter(Adapter) 58 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 59 | event = fake_group_message_event_v11( 60 | message=Message("/开启词云每日定时发送 10:"), sender={"role": "admin"} 61 | ) 62 | 63 | ctx.receive_event(bot, event) 64 | ctx.should_pass_permission(schedule_cmd) 65 | ctx.should_call_send(event, "请输入正确的时间,不然我没法理解呢!", True) 66 | ctx.should_finished(schedule_cmd) 67 | 68 | # OneBot V12 69 | async with app.test_matcher(schedule_cmd) as ctx: 70 | adapter = get_adapter(AdapterV12) 71 | bot = ctx.create_bot( 72 | base=BotV12, adapter=adapter, auto_connect=False, platform="qq", impl="test" 73 | ) 74 | event = fake_group_message_event_v12( 75 | message=MessageV12("/开启词云每日定时发送") 76 | ) 77 | 78 | ctx.receive_event(bot, event) 79 | ctx.should_ignore_permission(schedule_cmd) 80 | ctx.should_call_send( 81 | event, "已开启词云每日定时发送,发送时间为:22:00:00+08:00", True 82 | ) 83 | ctx.should_finished(schedule_cmd) 84 | 85 | assert len(schedule_service.schedules) == 2 86 | 87 | async with app.test_matcher(schedule_cmd) as ctx: 88 | adapter = get_adapter(AdapterV12) 89 | bot = ctx.create_bot( 90 | base=BotV12, adapter=adapter, auto_connect=False, platform="qq", impl="test" 91 | ) 92 | event = fake_channel_message_event_v12( 93 | message=MessageV12("/开启词云每日定时发送 09:00") 94 | ) 95 | 96 | ctx.receive_event(bot, event) 97 | ctx.should_ignore_permission(schedule_cmd) 98 | ctx.should_call_send( 99 | event, "已开启词云每日定时发送,发送时间为:09:00:00+08:00", True 100 | ) 101 | ctx.should_finished(schedule_cmd) 102 | 103 | assert len(schedule_service.schedules) == 3 104 | 105 | 106 | async def test_enable_schedule_private(app: App, mocker: MockerFixture): 107 | """测试私聊开启词云每日定时发送""" 108 | from nonebot_plugin_wordcloud import schedule_cmd 109 | 110 | config = get_driver().config 111 | 112 | mocker.patch.object(config, "superusers", {"10"}) 113 | 114 | async with app.test_matcher(schedule_cmd) as ctx: 115 | adapter = get_adapter(Adapter) 116 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 117 | event = fake_private_message_event_v11(message=Message("/开启词云每日定时发送")) 118 | ctx.receive_event(bot, event) 119 | ctx.should_pass_permission(schedule_cmd) 120 | ctx.should_call_send(event, "请在群组中使用!", True) 121 | ctx.should_finished(schedule_cmd) 122 | 123 | 124 | async def test_enable_schedule_without_permission(app: App, mocker: MockerFixture): 125 | """测试没有权限的用户开启词云每日定时发送""" 126 | from nonebot_plugin_wordcloud import schedule_cmd 127 | 128 | async with app.test_matcher(schedule_cmd) as ctx: 129 | adapter = get_adapter(Adapter) 130 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 131 | event = fake_group_message_event_v11(message=Message("/开启词云每日定时发送")) 132 | ctx.receive_event(bot, event) 133 | ctx.should_not_pass_permission(schedule_cmd) 134 | 135 | 136 | async def test_disable_schedule(app: App): 137 | from nonebot_plugin_orm import get_session 138 | 139 | from nonebot_plugin_wordcloud import schedule_cmd, schedule_service 140 | from nonebot_plugin_wordcloud.model import Schedule 141 | 142 | async with get_session() as session: 143 | schedule = Schedule( 144 | target={ 145 | "platform_type": "QQ Group", 146 | "group_id": 10000, 147 | }, 148 | time=time(14, 0), 149 | ) 150 | session.add(schedule) 151 | await session.commit() 152 | 153 | await schedule_service.update() 154 | assert len(schedule_service.schedules) == 2 155 | 156 | async with app.test_matcher(schedule_cmd) as ctx: 157 | adapter = get_adapter(Adapter) 158 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 159 | event = fake_group_message_event_v11( 160 | message=Message("/关闭词云每日定时发送"), sender={"role": "admin"} 161 | ) 162 | ctx.receive_event(bot, event) 163 | ctx.should_pass_permission(schedule_cmd) 164 | ctx.should_call_send(event, "已关闭词云每日定时发送", True) 165 | ctx.should_finished(schedule_cmd) 166 | 167 | async with get_session() as session: 168 | statement = select(Schedule) 169 | results = await session.scalars(statement) 170 | assert len(results.all()) == 0 171 | 172 | assert len(schedule_service.schedules) == 2 173 | 174 | 175 | async def test_schedule_status(app: App): 176 | from nonebot_plugin_saa import TargetQQGroup 177 | 178 | from nonebot_plugin_wordcloud import schedule_cmd, schedule_service 179 | 180 | async with app.test_matcher(schedule_cmd) as ctx: 181 | adapter = get_adapter(Adapter) 182 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 183 | event = fake_group_message_event_v11( 184 | message=Message("/词云每日定时发送状态"), sender={"role": "admin"} 185 | ) 186 | ctx.receive_event(bot, event) 187 | ctx.should_pass_permission(schedule_cmd) 188 | ctx.should_call_send(event, "词云每日定时发送未开启", True) 189 | ctx.should_finished(schedule_cmd) 190 | 191 | await schedule_service.add_schedule(TargetQQGroup(group_id=10000)) 192 | 193 | async with app.test_matcher(schedule_cmd) as ctx: 194 | adapter = get_adapter(Adapter) 195 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 196 | event = fake_group_message_event_v11( 197 | message=Message("/词云每日定时发送状态"), sender={"role": "admin"} 198 | ) 199 | ctx.receive_event(bot, event) 200 | ctx.should_pass_permission(schedule_cmd) 201 | ctx.should_call_send( 202 | event, "词云每日定时发送已开启,发送时间为:22:00:00+08:00", True 203 | ) 204 | ctx.should_finished(schedule_cmd) 205 | 206 | await schedule_service.add_schedule(TargetQQGroup(group_id=10000), time=time(23, 0)) 207 | 208 | async with app.test_matcher(schedule_cmd) as ctx: 209 | adapter = get_adapter(Adapter) 210 | bot = ctx.create_bot( 211 | base=Bot, adapter=adapter, auto_connect=False, self_id="test2" 212 | ) 213 | event = fake_group_message_event_v11( 214 | message=Message("/词云每日定时发送状态"), sender={"role": "admin"} 215 | ) 216 | ctx.receive_event(bot, event) 217 | ctx.should_pass_permission(schedule_cmd) 218 | ctx.should_call_send( 219 | event, "词云每日定时发送已开启,发送时间为:23:00:00+08:00", True 220 | ) 221 | ctx.should_finished(schedule_cmd) 222 | 223 | 224 | async def test_run_task_group(app: App, mocker: MockerFixture): 225 | from nonebot_plugin_saa import Image, MessageFactory, TargetQQGroup 226 | 227 | from nonebot_plugin_wordcloud import schedule_service 228 | 229 | image = BytesIO(b"test") 230 | target = TargetQQGroup(group_id=10000) 231 | await schedule_service.add_schedule(target) 232 | 233 | mocked_get_messages_plain_text = mocker.patch( 234 | "nonebot_plugin_wordcloud.schedule.get_messages_plain_text", 235 | return_value=["test"], 236 | ) 237 | mocked_get_wordcloud = mocker.patch( 238 | "nonebot_plugin_wordcloud.schedule.get_wordcloud", return_value=image 239 | ) 240 | 241 | async with app.test_api() as ctx: 242 | bot = ctx.create_bot(base=Bot) 243 | should_send_saa(ctx, MessageFactory(Image(image)), bot, target=target) 244 | await schedule_service.run_task() 245 | 246 | mocked_get_messages_plain_text.assert_called_once() 247 | mocked_get_wordcloud.assert_called_once_with(["test"], "qq_group-group_id=10000") 248 | 249 | # OneBot V12 250 | mocked_get_messages_plain_text_v12 = mocker.patch( 251 | "nonebot_plugin_wordcloud.schedule.get_messages_plain_text", 252 | return_value=["test"], 253 | ) 254 | 255 | mocked_get_wordcloud_v12 = mocker.patch( 256 | "nonebot_plugin_wordcloud.schedule.get_wordcloud", return_value=image 257 | ) 258 | 259 | async with app.test_api() as ctx: 260 | bot = ctx.create_bot(base=BotV12, platform="qq", impl="test") 261 | should_send_saa(ctx, MessageFactory(Image(image)), bot, target=target) 262 | await schedule_service.run_task() 263 | 264 | mocked_get_messages_plain_text_v12.assert_called_once() 265 | mocked_get_wordcloud_v12.assert_called_once_with( 266 | ["test"], "qq_group-group_id=10000" 267 | ) 268 | 269 | 270 | async def test_run_task_channel(app: App, mocker: MockerFixture): 271 | from nonebot_plugin_saa import Image, MessageFactory, TargetQQGuildChannel 272 | 273 | from nonebot_plugin_wordcloud import schedule_service 274 | 275 | image = BytesIO(b"test") 276 | target = TargetQQGuildChannel(channel_id=100000) 277 | await schedule_service.add_schedule(target) 278 | 279 | mocked_get_messages_plain_text = mocker.patch( 280 | "nonebot_plugin_wordcloud.schedule.get_messages_plain_text", 281 | return_value=["test"], 282 | ) 283 | mocked_get_wordcloud_v12 = mocker.patch( 284 | "nonebot_plugin_wordcloud.schedule.get_wordcloud", return_value=image 285 | ) 286 | 287 | async with app.test_api() as ctx: 288 | bot = ctx.create_bot(base=BotV12, impl="test", platform="qqguild") 289 | should_send_saa(ctx, MessageFactory(Image(image)), bot, target=target) 290 | await schedule_service.run_task() 291 | 292 | mocked_get_messages_plain_text.assert_called_once() 293 | mocked_get_wordcloud_v12.assert_called_once_with( 294 | ["test"], "qq_guild_channel-channel_id=100000" 295 | ) 296 | 297 | 298 | async def test_run_task_without_data(app: App, mocker: MockerFixture): 299 | from nonebot_plugin_saa import MessageFactory, TargetQQGroup, Text 300 | 301 | from nonebot_plugin_wordcloud import schedule_service 302 | 303 | target = TargetQQGroup(group_id=10000) 304 | await schedule_service.add_schedule(target) 305 | 306 | mocked_get_messages_plain_text = mocker.patch( 307 | "nonebot_plugin_wordcloud.schedule.get_messages_plain_text", 308 | return_value=["test"], 309 | ) 310 | mocked_get_wordcloud = mocker.patch( 311 | "nonebot_plugin_wordcloud.schedule.get_wordcloud", return_value=None 312 | ) 313 | 314 | async with app.test_api() as ctx: 315 | bot = ctx.create_bot(base=Bot) 316 | should_send_saa( 317 | ctx, MessageFactory(Text("今天没有足够的数据生成词云")), bot, target=target 318 | ) 319 | await schedule_service.run_task() 320 | 321 | mocked_get_messages_plain_text.assert_called_once() 322 | mocked_get_wordcloud.assert_called_once_with(["test"], "qq_group-group_id=10000") 323 | 324 | 325 | async def test_run_task_remove_schedule(app: App): 326 | """测试运行定时任务时,删除没有内容的定时任务""" 327 | from nonebot_plugin_saa import TargetQQGroup 328 | 329 | from nonebot_plugin_wordcloud.schedule import schedule_service 330 | 331 | assert "15:00:00" not in schedule_service.schedules 332 | assert "16:00:00" not in schedule_service.schedules 333 | 334 | await schedule_service.add_schedule(TargetQQGroup(group_id=10000), time=time(23, 0)) 335 | 336 | await schedule_service.update() 337 | 338 | assert "15:00:00" in schedule_service.schedules 339 | assert "16:00:00" not in schedule_service.schedules 340 | 341 | await schedule_service.add_schedule(TargetQQGroup(group_id=10000), time=time(0, 0)) 342 | 343 | await schedule_service.update() 344 | 345 | assert "15:00:00" in schedule_service.schedules 346 | assert "16:00:00" in schedule_service.schedules 347 | 348 | await schedule_service.run_task(time(15, 0)) 349 | 350 | assert "15:00:00" not in schedule_service.schedules 351 | assert "16:00:00" in schedule_service.schedules 352 | 353 | 354 | async def test_run_task_send_error(app: App, mocker: MockerFixture): 355 | """发送时出现错误""" 356 | from nonebot_plugin_saa import Image, MessageFactory, TargetQQGroup 357 | 358 | from nonebot_plugin_wordcloud import schedule_service 359 | 360 | image = BytesIO(b"test") 361 | target = TargetQQGroup(group_id=10000) 362 | target2 = TargetQQGroup(group_id=10001) 363 | await schedule_service.add_schedule(target) 364 | await schedule_service.add_schedule(target2) 365 | 366 | mocked_get_messages_plain_text = mocker.patch( 367 | "nonebot_plugin_wordcloud.schedule.get_messages_plain_text", 368 | return_value=["test"], 369 | ) 370 | mocked_get_wordcloud = mocker.patch( 371 | "nonebot_plugin_wordcloud.schedule.get_wordcloud", return_value=image 372 | ) 373 | 374 | async with app.test_api() as ctx: 375 | bot = ctx.create_bot(base=Bot) 376 | should_send_saa( 377 | ctx, 378 | MessageFactory(Image(image)), 379 | bot, 380 | target=target, 381 | exception=Exception("发送失败"), 382 | ) 383 | # 如果第一个群组发送失败,不应该影响第二个群组 384 | should_send_saa( 385 | ctx, 386 | MessageFactory(Image(image)), 387 | bot, 388 | target=target2, 389 | ) 390 | await schedule_service.run_task() 391 | 392 | assert mocked_get_messages_plain_text.call_count == 2 393 | mocked_get_wordcloud.assert_has_calls( 394 | [ 395 | mocker.call(["test"], "qq_group-group_id=10000"), 396 | mocker.call(["test"], "qq_group-group_id=10001"), 397 | ] # type: ignore 398 | ) 399 | -------------------------------------------------------------------------------- /tests/test_stopwords.py: -------------------------------------------------------------------------------- 1 | from nonebug import App 2 | 3 | 4 | async def test_stopwords(app: App): 5 | """测试设置停用词表""" 6 | from nonebot_plugin_localstore import get_data_file 7 | 8 | from nonebot_plugin_wordcloud.config import plugin_config 9 | from nonebot_plugin_wordcloud.data_source import analyse_message 10 | 11 | data = get_data_file("nonebot_plugin_wordcloud", "stopwords.txt") 12 | with data.open("w", encoding="utf8") as f: 13 | f.write("句子") 14 | 15 | message = "这是一个奇怪的句子。" 16 | frequency = analyse_message(message) 17 | assert frequency.keys() == {"这是", "一个", "奇怪", "句子"} 18 | 19 | plugin_config.wordcloud_stopwords_path = data 20 | 21 | frequency = analyse_message(message) 22 | assert frequency.keys() == {"这是", "一个", "奇怪"} 23 | -------------------------------------------------------------------------------- /tests/test_timezone.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, time 2 | from zoneinfo import ZoneInfo 3 | 4 | from nonebug import App 5 | from pytest_mock import MockerFixture 6 | 7 | 8 | async def test_get_datetime_now(app: App, mocker: MockerFixture): 9 | """测试获得当前时间""" 10 | from nonebot_plugin_wordcloud.config import plugin_config 11 | from nonebot_plugin_wordcloud.utils import get_datetime_now_with_timezone 12 | 13 | mocked_datetime = mocker.patch("nonebot_plugin_wordcloud.utils.datetime") 14 | mocked_datetime.now().astimezone.return_value = datetime( 15 | 2022, 1, 1, 6, tzinfo=ZoneInfo("Asia/Shanghai") 16 | ) 17 | 18 | assert get_datetime_now_with_timezone() == datetime( 19 | 2022, 1, 1, 6, tzinfo=ZoneInfo("Asia/Shanghai") 20 | ) 21 | 22 | # 通过调用 astimezone 来获取当前时区 23 | mocked_datetime.now().astimezone.assert_called_once_with() 24 | 25 | # 设置时区 26 | mocker.patch.object(plugin_config, "wordcloud_timezone", "UTC") 27 | 28 | mocked_datetime.now.return_value = datetime(2022, 1, 1, 6, tzinfo=ZoneInfo("UTC")) 29 | assert get_datetime_now_with_timezone() == datetime( 30 | 2022, 1, 1, 6, tzinfo=ZoneInfo("UTC") 31 | ) 32 | mocked_datetime.now.assert_called_with(ZoneInfo("UTC")) 33 | 34 | 35 | async def test_time(app: App, mocker: MockerFixture): 36 | """测试时间相关函数""" 37 | from nonebot_plugin_wordcloud.config import plugin_config 38 | from nonebot_plugin_wordcloud.utils import ( 39 | get_datetime_fromisoformat_with_timezone, 40 | get_time_fromisoformat_with_timezone, 41 | time_astimezone, 42 | ) 43 | 44 | # 测试从 iso 格式字符串获取时间 45 | assert ( 46 | get_datetime_fromisoformat_with_timezone("2022-01-01T10:00:00").isoformat() 47 | == "2022-01-01T10:00:00+08:00" 48 | ) 49 | assert ( 50 | get_datetime_fromisoformat_with_timezone( 51 | "2022-01-01T10:00:00+07:00" 52 | ).isoformat() 53 | == "2022-01-01T11:00:00+08:00" 54 | ) 55 | 56 | # 设置时区 57 | mocker.patch.object(plugin_config, "wordcloud_timezone", "UTC") 58 | 59 | assert ( 60 | get_datetime_fromisoformat_with_timezone("2022-01-01T10:00:00").isoformat() 61 | == "2022-01-01T10:00:00+00:00" 62 | ) 63 | assert ( 64 | get_datetime_fromisoformat_with_timezone( 65 | "2022-01-01T10:00:00+08:00" 66 | ).isoformat() 67 | == "2022-01-01T02:00:00+00:00" 68 | ) 69 | 70 | # 测试转换 time 对象时区 71 | assert time_astimezone(time(10, 0, 0)).isoformat() == "10:00:00+08:00" 72 | 73 | assert ( 74 | time_astimezone(time(10, 0, 0, tzinfo=ZoneInfo("UTC"))).isoformat() 75 | == "18:00:00+08:00" 76 | ) 77 | 78 | # 测试从 iso 格式字符串获取时间 79 | mocker.patch.object(plugin_config, "wordcloud_timezone", None) 80 | assert ( 81 | get_time_fromisoformat_with_timezone("10:00:00").isoformat() == "10:00:00+08:00" 82 | ) 83 | assert ( 84 | get_time_fromisoformat_with_timezone("10:00:00+07:00").isoformat() 85 | == "11:00:00+08:00" 86 | ) 87 | 88 | mocker.patch.object(plugin_config, "wordcloud_timezone", "UTC") 89 | 90 | assert ( 91 | get_time_fromisoformat_with_timezone("10:00:00").isoformat() == "10:00:00+00:00" 92 | ) 93 | assert ( 94 | get_time_fromisoformat_with_timezone("10:00:00+08:00").isoformat() 95 | == "02:00:00+00:00" 96 | ) 97 | -------------------------------------------------------------------------------- /tests/test_userdict.py: -------------------------------------------------------------------------------- 1 | from nonebug import App 2 | from pytest_mock import MockerFixture 3 | 4 | 5 | async def test_userdict(app: App, mocker: MockerFixture): 6 | """测试添加用户词典""" 7 | from nonebot_plugin_localstore import get_data_file 8 | 9 | from nonebot_plugin_wordcloud.config import plugin_config 10 | from nonebot_plugin_wordcloud.data_source import analyse_message 11 | 12 | data = get_data_file("nonebot_plugin_wordcloud", "userdict.txt") 13 | with data.open("w", encoding="utf8") as f: 14 | f.write("小脑芙") 15 | 16 | message = "小脑芙真可爱!" 17 | frequency = analyse_message(message) 18 | assert frequency.keys() == {"小脑", "芙真", "可爱"} 19 | 20 | mocker.patch.object(plugin_config, "wordcloud_userdict_path", data) 21 | 22 | frequency = analyse_message(message) 23 | assert frequency.keys() == {"小脑芙", "可爱"} 24 | -------------------------------------------------------------------------------- /tests/test_wordcloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/he0119/nonebot-plugin-wordcloud/ef14367c14c6a50cb171834f577f4e9127d31bc6/tests/test_wordcloud.png -------------------------------------------------------------------------------- /tests/test_wordcloud.py: -------------------------------------------------------------------------------- 1 | import random 2 | from datetime import datetime 3 | from io import BytesIO 4 | from pathlib import Path 5 | from zoneinfo import ZoneInfo 6 | 7 | import pytest 8 | from nonebot import get_adapter, get_driver 9 | from nonebot.adapters.onebot.v11 import Adapter, Bot, Message 10 | from nonebot.adapters.onebot.v12 import Adapter as AdapterV12 11 | from nonebot.adapters.onebot.v12 import Bot as BotV12 12 | from nonebot.adapters.onebot.v12 import Message as MessageV12 13 | from nonebug import App 14 | from nonebug_saa import should_send_saa 15 | from PIL import Image, ImageChops 16 | from pytest_mock import MockerFixture 17 | 18 | from .utils import ( 19 | fake_channel_message_event_v12, 20 | fake_group_message_event_v11, 21 | fake_group_message_event_v12, 22 | fake_private_message_event_v11, 23 | ) 24 | 25 | FAKE_IMAGE = BytesIO( 26 | b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe\r\xefF\xb8\x00\x00\x00\x00IEND\xaeB`\x82" 27 | ) 28 | 29 | 30 | @pytest.fixture 31 | async def _message_record(app: App): 32 | from nonebot_plugin_chatrecorder import serialize_message 33 | from nonebot_plugin_chatrecorder.model import MessageRecord 34 | from nonebot_plugin_orm import get_session 35 | from nonebot_plugin_uninfo import ( 36 | Scene, 37 | SceneType, 38 | Session, 39 | SupportAdapter, 40 | SupportScope, 41 | User, 42 | ) 43 | from nonebot_plugin_uninfo.orm import get_session_persist_id 44 | 45 | async with app.test_api() as ctx: 46 | adapter = Adapter(get_driver()) 47 | adapter_v12 = AdapterV12(get_driver()) 48 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 49 | bot_v12 = ctx.create_bot( 50 | base=BotV12, 51 | adapter=adapter_v12, 52 | auto_connect=False, 53 | platform="test", 54 | impl="test", 55 | ) 56 | 57 | sessions = [ 58 | Session( 59 | self_id="test", 60 | adapter=SupportAdapter.onebot11, 61 | scope=SupportScope.qq_client, 62 | scene=Scene("10000", SceneType.GROUP), 63 | user=User("bot"), 64 | ), 65 | Session( 66 | self_id="test1", 67 | adapter=SupportAdapter.onebot11, 68 | scope=SupportScope.qq_client, 69 | scene=Scene("10000", SceneType.GROUP), 70 | user=User("10"), 71 | ), 72 | Session( 73 | self_id="test", 74 | adapter=SupportAdapter.onebot11, 75 | scope=SupportScope.qq_client, 76 | scene=Scene("10000", SceneType.GROUP), 77 | user=User("11"), 78 | ), 79 | Session( 80 | self_id="test1", 81 | adapter=SupportAdapter.onebot12, 82 | scope=SupportScope.qq_guild, 83 | scene=Scene( 84 | "100000", SceneType.CHANNEL_TEXT, parent=Scene("10000", SceneType.GUILD) 85 | ), 86 | user=User("10"), 87 | ), 88 | Session( 89 | self_id="test", 90 | adapter=SupportAdapter.onebot12, 91 | scope=SupportScope.qq_guild, 92 | scene=Scene( 93 | "100000", SceneType.CHANNEL_TEXT, parent=Scene("10000", SceneType.GUILD) 94 | ), 95 | user=User("11"), 96 | ), 97 | ] 98 | session_ids: list[int] = [] 99 | async with get_session() as db_session: 100 | for session in sessions: 101 | session_model_id = await get_session_persist_id(session) 102 | session_ids.append(session_model_id) 103 | 104 | records = [ 105 | # 星期日 106 | MessageRecord( 107 | session_persist_id=session_ids[0], 108 | time=datetime(2022, 1, 2, 4, 0, 0), 109 | type="message_sent", 110 | message_id="1", 111 | message=serialize_message(bot, Message("bot:1-2")), 112 | plain_text="bot:1-2", 113 | ), 114 | MessageRecord( 115 | session_persist_id=session_ids[1], 116 | time=datetime(2022, 1, 2, 4, 0, 0), 117 | type="message", 118 | message_id="2", 119 | message=serialize_message(bot, Message("10:1-2")), 120 | plain_text="10:1-2", 121 | ), 122 | MessageRecord( 123 | session_persist_id=session_ids[2], 124 | time=datetime(2022, 1, 2, 4, 0, 0), 125 | type="message", 126 | message_id="3", 127 | message=serialize_message(bot, Message("11:1-2")), 128 | plain_text="11:1-2", 129 | ), 130 | MessageRecord( 131 | session_persist_id=session_ids[3], 132 | time=datetime(2022, 1, 2, 4, 0, 0), 133 | type="message", 134 | message_id="4", 135 | message=serialize_message(bot_v12, MessageV12("v12-10:1-2")), 136 | plain_text="v12-10:1-2", 137 | ), 138 | MessageRecord( 139 | session_persist_id=session_ids[4], 140 | time=datetime(2022, 1, 2, 4, 0, 0), 141 | type="message", 142 | message_id="4", 143 | message=serialize_message(bot_v12, MessageV12("v12-11:1-2")), 144 | plain_text="v12-11:1-2", 145 | ), 146 | # 星期一 147 | MessageRecord( 148 | session_persist_id=session_ids[1], 149 | time=datetime(2022, 1, 3, 4, 0, 0), 150 | type="message", 151 | message_id="2", 152 | message=serialize_message(bot, Message("10:1-3")), 153 | plain_text="10:1-3", 154 | ), 155 | MessageRecord( 156 | session_persist_id=session_ids[2], 157 | time=datetime(2022, 1, 3, 4, 0, 0), 158 | type="message", 159 | message_id="3", 160 | message=serialize_message(bot, Message("11:1-3")), 161 | plain_text="11:1-3", 162 | ), 163 | # 星期二 164 | MessageRecord( 165 | session_persist_id=session_ids[1], 166 | time=datetime(2022, 2, 1, 4, 0, 0), 167 | type="message", 168 | message_id="2", 169 | message=serialize_message(bot, Message("10:2-1")), 170 | plain_text="10:2-1", 171 | ), 172 | MessageRecord( 173 | session_persist_id=session_ids[2], 174 | time=datetime(2022, 2, 1, 4, 0, 0), 175 | type="message", 176 | message_id="3", 177 | message=serialize_message(bot, Message("11:2-1")), 178 | plain_text="11:2-1", 179 | ), 180 | ] 181 | async with get_session() as db_session: 182 | db_session.add_all(records) 183 | await db_session.commit() 184 | 185 | 186 | async def test_get_wordcloud(app: App, mocker: MockerFixture): 187 | """测试生成词云""" 188 | from nonebot_plugin_wordcloud.data_source import get_wordcloud 189 | 190 | mocked_random = mocker.patch("wordcloud.wordcloud.Random") 191 | mocked_random.return_value = random.Random(0) 192 | 193 | image_byte = await get_wordcloud(["天气"], "") 194 | 195 | assert image_byte is not None 196 | 197 | # 比较生成的图片是否相同 198 | test_image_path = Path(__file__).parent / "test_wordcloud.png" 199 | test_image = Image.open(test_image_path) 200 | image = Image.open(BytesIO(image_byte)) 201 | diff = ImageChops.difference(image, test_image) 202 | assert diff.getbbox() is None 203 | 204 | mocked_random.assert_called_once_with() 205 | 206 | 207 | async def test_get_wordcloud_private(app: App): 208 | """测试私聊词云""" 209 | from nonebot_plugin_wordcloud import wordcloud_cmd 210 | 211 | async with app.test_matcher(wordcloud_cmd) as ctx: 212 | adapter = get_adapter(Adapter) 213 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 214 | event = fake_private_message_event_v11(message=Message("/词云")) 215 | 216 | ctx.receive_event(bot, event) 217 | ctx.should_call_send( 218 | event, 219 | "请在群组中使用!", 220 | True, 221 | ) 222 | ctx.should_finished() 223 | 224 | 225 | async def test_wordcloud_cmd(app: App): 226 | """测试输出帮助信息与没有数据的情况""" 227 | from nonebot_plugin_wordcloud import __plugin_meta__, wordcloud_cmd 228 | 229 | async with app.test_matcher(wordcloud_cmd) as ctx: 230 | adapter = get_adapter(Adapter) 231 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 232 | event = fake_group_message_event_v11(message=Message("/词云")) 233 | 234 | ctx.receive_event(bot, event) 235 | ctx.should_call_send( 236 | event, 237 | __plugin_meta__.usage, 238 | True, 239 | ) 240 | ctx.should_finished() 241 | 242 | async with app.test_matcher(wordcloud_cmd) as ctx: 243 | adapter = get_adapter(Adapter) 244 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 245 | event = fake_group_message_event_v11(message=Message("/词云 123")) 246 | 247 | ctx.receive_event(bot, event) 248 | ctx.should_finished() 249 | 250 | async with app.test_matcher(wordcloud_cmd) as ctx: 251 | adapter = get_adapter(Adapter) 252 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 253 | event = fake_group_message_event_v11(message=Message("/今日词云")) 254 | 255 | ctx.receive_event(bot, event) 256 | ctx.should_call_send( 257 | event, "没有足够的数据生成词云", True, at_sender=False, reply=False 258 | ) 259 | ctx.should_finished() 260 | 261 | 262 | @pytest.mark.usefixtures("_message_record") 263 | async def test_today_wordcloud(app: App, mocker: MockerFixture): 264 | """测试今日词云""" 265 | from nonebot_plugin_chatrecorder import get_messages_plain_text 266 | from nonebot_plugin_saa import Image, MessageFactory 267 | 268 | from nonebot_plugin_wordcloud import wordcloud_cmd 269 | 270 | # 排除机器人自己的消息 271 | messages = await get_messages_plain_text() 272 | assert len(messages) == 9 273 | 274 | mocked_datetime_now = mocker.patch( 275 | "nonebot_plugin_wordcloud.get_datetime_now_with_timezone", 276 | return_value=datetime(2022, 1, 2, 23, tzinfo=ZoneInfo("Asia/Shanghai")), 277 | ) 278 | 279 | mocked_get_wordcloud = mocker.patch( 280 | "nonebot_plugin_wordcloud.get_wordcloud", 281 | return_value=FAKE_IMAGE, 282 | ) 283 | 284 | async with app.test_matcher(wordcloud_cmd) as ctx: 285 | adapter = get_adapter(Adapter) 286 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 287 | event = fake_group_message_event_v11(message=Message("/今日词云")) 288 | 289 | ctx.receive_event(bot, event) 290 | should_send_saa( 291 | ctx, MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), bot, event=event 292 | ) 293 | ctx.should_finished() 294 | 295 | mocked_datetime_now.assert_called_once_with() 296 | mocked_get_wordcloud.assert_called_once_with( 297 | ["10:1-2", "11:1-2"], "qq_group-group_id=10000" 298 | ) 299 | 300 | 301 | @pytest.mark.usefixtures("_message_record") 302 | async def test_my_today_wordcloud(app: App, mocker: MockerFixture): 303 | """测试我的今日词云""" 304 | from nonebot_plugin_saa import Image, MessageFactory 305 | 306 | from nonebot_plugin_wordcloud import wordcloud_cmd 307 | 308 | mocked_datetime_now = mocker.patch( 309 | "nonebot_plugin_wordcloud.get_datetime_now_with_timezone", 310 | return_value=datetime(2022, 1, 2, 23, tzinfo=ZoneInfo("Asia/Shanghai")), 311 | ) 312 | 313 | mocked_get_wordcloud = mocker.patch( 314 | "nonebot_plugin_wordcloud.get_wordcloud", 315 | return_value=FAKE_IMAGE, 316 | ) 317 | 318 | async with app.test_matcher(wordcloud_cmd) as ctx: 319 | adapter = get_adapter(Adapter) 320 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 321 | event = fake_group_message_event_v11(message=Message("/我的今日词云")) 322 | 323 | ctx.receive_event(bot, event) 324 | should_send_saa( 325 | ctx, 326 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 327 | bot, 328 | event=event, 329 | at_sender=True, 330 | ) 331 | ctx.should_finished() 332 | 333 | mocked_datetime_now.assert_called_once_with() 334 | mocked_get_wordcloud.assert_called_once_with(["10:1-2"], "qq_group-group_id=10000") 335 | 336 | 337 | @pytest.mark.usefixtures("_message_record") 338 | async def test_yesterday_wordcloud(app: App, mocker: MockerFixture): 339 | """测试昨日词云""" 340 | from nonebot_plugin_saa import Image, MessageFactory 341 | 342 | from nonebot_plugin_wordcloud import wordcloud_cmd 343 | 344 | mocked_datetime_now = mocker.patch( 345 | "nonebot_plugin_wordcloud.get_datetime_now_with_timezone", 346 | return_value=datetime(2022, 1, 3, 2, tzinfo=ZoneInfo("Asia/Shanghai")), 347 | ) 348 | 349 | mocked_get_wordcloud = mocker.patch( 350 | "nonebot_plugin_wordcloud.get_wordcloud", 351 | return_value=FAKE_IMAGE, 352 | ) 353 | 354 | async with app.test_matcher(wordcloud_cmd) as ctx: 355 | adapter = get_adapter(Adapter) 356 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 357 | event = fake_group_message_event_v11(message=Message("/昨日词云")) 358 | 359 | ctx.receive_event(bot, event) 360 | should_send_saa( 361 | ctx, 362 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 363 | bot, 364 | event=event, 365 | ) 366 | ctx.should_finished() 367 | 368 | mocked_datetime_now.assert_called_once_with() 369 | mocked_get_wordcloud.assert_called_once_with( 370 | ["10:1-2", "11:1-2"], "qq_group-group_id=10000" 371 | ) 372 | 373 | 374 | @pytest.mark.usefixtures("_message_record") 375 | async def test_my_yesterday_wordcloud(app: App, mocker: MockerFixture): 376 | """测试我的昨日词云""" 377 | from nonebot_plugin_saa import Image, MessageFactory 378 | 379 | from nonebot_plugin_wordcloud import wordcloud_cmd 380 | 381 | mocked_datetime_now = mocker.patch( 382 | "nonebot_plugin_wordcloud.get_datetime_now_with_timezone", 383 | return_value=datetime(2022, 1, 3, 2, tzinfo=ZoneInfo("Asia/Shanghai")), 384 | ) 385 | 386 | mocked_get_wordcloud = mocker.patch( 387 | "nonebot_plugin_wordcloud.get_wordcloud", 388 | return_value=FAKE_IMAGE, 389 | ) 390 | 391 | async with app.test_matcher(wordcloud_cmd) as ctx: 392 | adapter = get_adapter(Adapter) 393 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 394 | event = fake_group_message_event_v11(message=Message("/我的昨日词云")) 395 | 396 | ctx.receive_event(bot, event) 397 | should_send_saa( 398 | ctx, 399 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 400 | bot, 401 | event=event, 402 | at_sender=True, 403 | ) 404 | ctx.should_finished() 405 | 406 | mocked_datetime_now.assert_called_once_with() 407 | mocked_get_wordcloud.assert_called_once_with(["10:1-2"], "qq_group-group_id=10000") 408 | 409 | 410 | @pytest.mark.usefixtures("_message_record") 411 | async def test_week_wordcloud(app: App, mocker: MockerFixture): 412 | """测试本周词云""" 413 | from nonebot_plugin_saa import Image, MessageFactory 414 | 415 | from nonebot_plugin_wordcloud import wordcloud_cmd 416 | 417 | mocked_datetime_now = mocker.patch( 418 | "nonebot_plugin_wordcloud.get_datetime_now_with_timezone", 419 | return_value=datetime(2022, 1, 5, 2, tzinfo=ZoneInfo("Asia/Shanghai")), 420 | ) 421 | 422 | mocked_get_wordcloud = mocker.patch( 423 | "nonebot_plugin_wordcloud.get_wordcloud", 424 | return_value=FAKE_IMAGE, 425 | ) 426 | 427 | async with app.test_matcher(wordcloud_cmd) as ctx: 428 | adapter = get_adapter(Adapter) 429 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 430 | event = fake_group_message_event_v11(message=Message("/本周词云")) 431 | 432 | ctx.receive_event(bot, event) 433 | should_send_saa( 434 | ctx, 435 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 436 | bot, 437 | event=event, 438 | ) 439 | ctx.should_finished() 440 | 441 | mocked_datetime_now.assert_called_once_with() 442 | mocked_get_wordcloud.assert_called_once_with( 443 | ["10:1-3", "11:1-3"], "qq_group-group_id=10000" 444 | ) 445 | 446 | 447 | @pytest.mark.usefixtures("_message_record") 448 | async def test_last_week_wordcloud(app: App, mocker: MockerFixture): 449 | """测试上周词云""" 450 | from nonebot_plugin_saa import Image, MessageFactory 451 | 452 | from nonebot_plugin_wordcloud import wordcloud_cmd 453 | 454 | mocked_datetime_now = mocker.patch( 455 | "nonebot_plugin_wordcloud.get_datetime_now_with_timezone", 456 | return_value=datetime(2022, 1, 5, 2, tzinfo=ZoneInfo("Asia/Shanghai")), 457 | ) 458 | 459 | mocked_get_wordcloud = mocker.patch( 460 | "nonebot_plugin_wordcloud.get_wordcloud", 461 | return_value=FAKE_IMAGE, 462 | ) 463 | 464 | async with app.test_matcher(wordcloud_cmd) as ctx: 465 | adapter = get_adapter(Adapter) 466 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 467 | event = fake_group_message_event_v11(message=Message("/上周词云")) 468 | 469 | ctx.receive_event(bot, event) 470 | should_send_saa( 471 | ctx, 472 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 473 | bot, 474 | event=event, 475 | ) 476 | ctx.should_finished() 477 | 478 | mocked_datetime_now.assert_called_once_with() 479 | mocked_get_wordcloud.assert_called_once_with( 480 | ["10:1-2", "11:1-2"], "qq_group-group_id=10000" 481 | ) 482 | 483 | 484 | @pytest.mark.usefixtures("_message_record") 485 | async def test_month_wordcloud(app: App, mocker: MockerFixture): 486 | """测试本月词云""" 487 | from nonebot_plugin_saa import Image, MessageFactory 488 | 489 | from nonebot_plugin_wordcloud import wordcloud_cmd 490 | 491 | mocked_datetime_now = mocker.patch( 492 | "nonebot_plugin_wordcloud.get_datetime_now_with_timezone", 493 | return_value=datetime(2022, 2, 7, 2, tzinfo=ZoneInfo("Asia/Shanghai")), 494 | ) 495 | 496 | mocked_get_wordcloud = mocker.patch( 497 | "nonebot_plugin_wordcloud.get_wordcloud", 498 | return_value=FAKE_IMAGE, 499 | ) 500 | 501 | async with app.test_matcher(wordcloud_cmd) as ctx: 502 | adapter = get_adapter(Adapter) 503 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 504 | event = fake_group_message_event_v11(message=Message("/本月词云")) 505 | 506 | ctx.receive_event(bot, event) 507 | should_send_saa( 508 | ctx, 509 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 510 | bot, 511 | event=event, 512 | ) 513 | ctx.should_finished() 514 | 515 | mocked_datetime_now.assert_called_once_with() 516 | mocked_get_wordcloud.assert_called_once_with( 517 | ["10:2-1", "11:2-1"], "qq_group-group_id=10000" 518 | ) 519 | 520 | 521 | @pytest.mark.usefixtures("_message_record") 522 | async def test_last_month_wordcloud(app: App, mocker: MockerFixture): 523 | """测试上月词云""" 524 | from nonebot_plugin_orm import get_session 525 | 526 | engine = get_session().get_bind() 527 | if engine.dialect.name == "mysql": 528 | pytest.skip("MySQL 上获取消息的顺序不同") 529 | 530 | from nonebot_plugin_saa import Image, MessageFactory 531 | 532 | from nonebot_plugin_wordcloud import wordcloud_cmd 533 | 534 | mocked_datetime_now = mocker.patch( 535 | "nonebot_plugin_wordcloud.get_datetime_now_with_timezone", 536 | return_value=datetime(2022, 2, 7, 2, tzinfo=ZoneInfo("Asia/Shanghai")), 537 | ) 538 | 539 | mocked_get_wordcloud = mocker.patch( 540 | "nonebot_plugin_wordcloud.get_wordcloud", 541 | return_value=FAKE_IMAGE, 542 | ) 543 | 544 | async with app.test_matcher(wordcloud_cmd) as ctx: 545 | adapter = get_adapter(Adapter) 546 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 547 | event = fake_group_message_event_v11(message=Message("/上月词云")) 548 | 549 | ctx.receive_event(bot, event) 550 | should_send_saa( 551 | ctx, 552 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 553 | bot, 554 | event=event, 555 | ) 556 | ctx.should_finished() 557 | 558 | mocked_datetime_now.assert_called_once_with() 559 | mocked_get_wordcloud.assert_called_once_with( 560 | ["10:1-2", "11:1-2", "10:1-3", "11:1-3"], "qq_group-group_id=10000" 561 | ) 562 | 563 | 564 | @pytest.mark.usefixtures("_message_record") 565 | async def test_year_wordcloud(app: App, mocker: MockerFixture): 566 | """测试年度词云""" 567 | from nonebot_plugin_orm import get_session 568 | 569 | engine = get_session().get_bind() 570 | if engine.dialect.name == "mysql": 571 | pytest.skip("MySQL 上获取消息的顺序不同") 572 | 573 | from nonebot_plugin_saa import Image, MessageFactory 574 | 575 | from nonebot_plugin_wordcloud import wordcloud_cmd 576 | 577 | mocked_datetime_now = mocker.patch( 578 | "nonebot_plugin_wordcloud.get_datetime_now_with_timezone", 579 | return_value=datetime(2022, 12, 1, 2, tzinfo=ZoneInfo("Asia/Shanghai")), 580 | ) 581 | 582 | mocked_get_wordcloud = mocker.patch( 583 | "nonebot_plugin_wordcloud.get_wordcloud", 584 | return_value=FAKE_IMAGE, 585 | ) 586 | 587 | async with app.test_matcher(wordcloud_cmd) as ctx: 588 | adapter = get_adapter(Adapter) 589 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 590 | event = fake_group_message_event_v11(message=Message("/年度词云")) 591 | 592 | ctx.receive_event(bot, event) 593 | should_send_saa( 594 | ctx, 595 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 596 | bot, 597 | event=event, 598 | ) 599 | ctx.should_finished() 600 | 601 | mocked_datetime_now.assert_called_once_with() 602 | mocked_get_wordcloud.assert_called_once_with( 603 | ["10:1-2", "11:1-2", "10:1-3", "11:1-3", "10:2-1", "11:2-1"], 604 | "qq_group-group_id=10000", 605 | ) 606 | 607 | 608 | @pytest.mark.usefixtures("_message_record") 609 | async def test_my_year_wordcloud(app: App, mocker: MockerFixture): 610 | """测试我的年度词云""" 611 | from nonebot_plugin_saa import Image, MessageFactory 612 | 613 | from nonebot_plugin_wordcloud import wordcloud_cmd 614 | 615 | mocked_datetime_now = mocker.patch( 616 | "nonebot_plugin_wordcloud.get_datetime_now_with_timezone", 617 | return_value=datetime(2022, 12, 1, 2, tzinfo=ZoneInfo("Asia/Shanghai")), 618 | ) 619 | 620 | mocked_get_wordcloud = mocker.patch( 621 | "nonebot_plugin_wordcloud.get_wordcloud", 622 | return_value=FAKE_IMAGE, 623 | ) 624 | 625 | async with app.test_matcher(wordcloud_cmd) as ctx: 626 | adapter = get_adapter(Adapter) 627 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 628 | event = fake_group_message_event_v11(message=Message("/我的年度词云")) 629 | 630 | ctx.receive_event(bot, event) 631 | should_send_saa( 632 | ctx, 633 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 634 | bot, 635 | event=event, 636 | at_sender=True, 637 | ) 638 | ctx.should_finished() 639 | 640 | mocked_datetime_now.assert_called_once_with() 641 | mocked_get_wordcloud.assert_called_once_with( 642 | ["10:1-2", "10:1-3", "10:2-1"], "qq_group-group_id=10000" 643 | ) 644 | 645 | 646 | @pytest.mark.usefixtures("_message_record") 647 | async def test_history_wordcloud(app: App, mocker: MockerFixture): 648 | """测试历史词云""" 649 | from nonebot_plugin_saa import Image, MessageFactory 650 | 651 | from nonebot_plugin_wordcloud import wordcloud_cmd 652 | 653 | mocked_get_wordcloud = mocker.patch( 654 | "nonebot_plugin_wordcloud.get_wordcloud", 655 | return_value=FAKE_IMAGE, 656 | ) 657 | 658 | async with app.test_matcher(wordcloud_cmd) as ctx: 659 | adapter = get_adapter(Adapter) 660 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 661 | event = fake_group_message_event_v11(message=Message("/历史词云 2022-01-02")) 662 | 663 | ctx.receive_event(bot, event) 664 | should_send_saa( 665 | ctx, 666 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 667 | bot, 668 | event=event, 669 | ) 670 | ctx.should_finished() 671 | 672 | mocked_get_wordcloud.assert_called_once_with( 673 | ["10:1-2", "11:1-2"], "qq_group-group_id=10000" 674 | ) 675 | 676 | 677 | @pytest.mark.usefixtures("_message_record") 678 | async def test_history_wordcloud_start_stop(app: App, mocker: MockerFixture): 679 | """测试历史词云,有起始时间的情况""" 680 | from nonebot_plugin_orm import get_session 681 | 682 | engine = get_session().get_bind() 683 | if engine.dialect.name == "mysql": 684 | pytest.skip("MySQL 上获取消息的顺序不同") 685 | 686 | from nonebot_plugin_saa import Image, MessageFactory 687 | 688 | from nonebot_plugin_wordcloud import wordcloud_cmd 689 | 690 | mocked_get_wordcloud = mocker.patch( 691 | "nonebot_plugin_wordcloud.get_wordcloud", 692 | return_value=FAKE_IMAGE, 693 | ) 694 | 695 | async with app.test_matcher(wordcloud_cmd) as ctx: 696 | adapter = get_adapter(Adapter) 697 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 698 | event = fake_group_message_event_v11( 699 | message=Message("/历史词云 2022-01-02T12:00:01~2022-02-22") 700 | ) 701 | 702 | ctx.receive_event(bot, event) 703 | should_send_saa( 704 | ctx, 705 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 706 | bot, 707 | event=event, 708 | ) 709 | ctx.should_finished() 710 | 711 | mocked_get_wordcloud.assert_called_once_with( 712 | ["10:1-3", "11:1-3", "10:2-1", "11:2-1"], "qq_group-group_id=10000" 713 | ) 714 | 715 | 716 | @pytest.mark.usefixtures("_message_record") 717 | async def test_history_wordcloud_start_stop_get_args(app: App, mocker: MockerFixture): 718 | """测试历史词云,获取起始时间参数的情况""" 719 | from nonebot_plugin_orm import get_session 720 | 721 | engine = get_session().get_bind() 722 | if engine.dialect.name == "mysql": 723 | pytest.skip("MySQL 上获取消息的顺序不同") 724 | 725 | from nonebot_plugin_saa import Image, MessageFactory 726 | 727 | from nonebot_plugin_wordcloud import wordcloud_cmd 728 | 729 | mocked_get_wordcloud = mocker.patch( 730 | "nonebot_plugin_wordcloud.get_wordcloud", 731 | return_value=FAKE_IMAGE, 732 | ) 733 | 734 | async with app.test_matcher(wordcloud_cmd) as ctx: 735 | adapter = get_adapter(Adapter) 736 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 737 | 738 | event = fake_group_message_event_v11(message=Message("/历史词云")) 739 | ctx.receive_event(bot, event) 740 | ctx.should_call_send(event, "请输入你要查询的起始日期(如 2022-01-01)", True) 741 | ctx.should_rejected() 742 | 743 | start_event = fake_group_message_event_v11(message=Message("2022-01-01")) 744 | ctx.receive_event(bot, start_event) 745 | ctx.should_call_send( 746 | start_event, "请输入你要查询的结束日期(如 2022-02-22)", True 747 | ) 748 | ctx.should_rejected() 749 | 750 | invalid_stop_event = fake_group_message_event_v11(message=Message("2022-02-30")) 751 | ctx.receive_event(bot, invalid_stop_event) 752 | ctx.should_call_send( 753 | invalid_stop_event, "请输入正确的日期,不然我没法理解呢!", True 754 | ) 755 | ctx.should_rejected() 756 | 757 | stop_event = fake_group_message_event_v11(message=Message("2022-02-22")) 758 | ctx.receive_event(bot, stop_event) 759 | should_send_saa( 760 | ctx, 761 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 762 | bot, 763 | event=stop_event, 764 | ) 765 | ctx.should_finished() 766 | 767 | mocked_get_wordcloud.assert_called_once_with( 768 | ["10:1-2", "11:1-2", "10:1-3", "11:1-3", "10:2-1", "11:2-1"], 769 | "qq_group-group_id=10000", 770 | ) 771 | 772 | 773 | async def test_history_wordcloud_invalid_input(app: App): 774 | """测试历史词云,输入的日期无效""" 775 | from nonebot_plugin_wordcloud import wordcloud_cmd 776 | 777 | async with app.test_matcher(wordcloud_cmd) as ctx: 778 | adapter = get_adapter(Adapter) 779 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 780 | event = fake_group_message_event_v11(message=Message("/历史词云 2022-13-01")) 781 | 782 | ctx.receive_event(bot, event) 783 | ctx.should_call_send(event, "请输入正确的日期,不然我没法理解呢!", True) 784 | ctx.should_finished() 785 | 786 | async with app.test_matcher(wordcloud_cmd) as ctx: 787 | adapter = get_adapter(Adapter) 788 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 789 | event = fake_group_message_event_v11( 790 | message=Message("/历史词云 2022-12-01T13:~2022-12-02") 791 | ) 792 | 793 | ctx.receive_event(bot, event) 794 | ctx.should_call_send(event, "请输入正确的日期,不然我没法理解呢!", True) 795 | ctx.should_finished() 796 | 797 | async with app.test_matcher(wordcloud_cmd) as ctx: 798 | adapter = get_adapter(Adapter) 799 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 800 | event = fake_group_message_event_v11( 801 | message=Message("/历史词云 2022-12-01~2022-13-01") 802 | ) 803 | 804 | ctx.receive_event(bot, event) 805 | ctx.should_call_send(event, "请输入正确的日期,不然我没法理解呢!", True) 806 | ctx.should_finished() 807 | 808 | 809 | @pytest.mark.usefixtures("_message_record") 810 | async def test_today_wordcloud_v12(app: App, mocker: MockerFixture): 811 | from nonebot_plugin_saa import Image, MessageFactory 812 | 813 | from nonebot_plugin_wordcloud import wordcloud_cmd 814 | 815 | mocked_datetime_now = mocker.patch( 816 | "nonebot_plugin_wordcloud.get_datetime_now_with_timezone", 817 | return_value=datetime(2022, 1, 2, 23, tzinfo=ZoneInfo("Asia/Shanghai")), 818 | ) 819 | 820 | mocked_get_wordcloud = mocker.patch( 821 | "nonebot_plugin_wordcloud.get_wordcloud", 822 | return_value=FAKE_IMAGE, 823 | ) 824 | async with app.test_matcher(wordcloud_cmd) as ctx: 825 | adapter = get_adapter(AdapterV12) 826 | bot = ctx.create_bot( 827 | base=BotV12, 828 | adapter=adapter, 829 | auto_connect=False, 830 | platform="test", 831 | impl="test", 832 | ) 833 | event = fake_channel_message_event_v12(message=MessageV12("/今日词云")) 834 | 835 | ctx.receive_event(bot, event) 836 | should_send_saa( 837 | ctx, 838 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 839 | bot, 840 | event=event, 841 | ) 842 | ctx.should_finished() 843 | 844 | mocked_datetime_now.assert_called_once_with() 845 | mocked_get_wordcloud.assert_called_once_with( 846 | ["v12-10:1-2", "v12-11:1-2"], 847 | "unknown_ob12-platform=qq-detail_type=channel-guild_id=10000-channel_id=100000", 848 | ) 849 | 850 | 851 | @pytest.mark.usefixtures("_message_record") 852 | async def test_my_today_wordcloud_v12(app: App, mocker: MockerFixture): 853 | from nonebot_plugin_saa import Image, MessageFactory 854 | 855 | from nonebot_plugin_wordcloud import wordcloud_cmd 856 | 857 | mocked_datetime_now = mocker.patch( 858 | "nonebot_plugin_wordcloud.get_datetime_now_with_timezone", 859 | return_value=datetime(2022, 1, 2, 23, tzinfo=ZoneInfo("Asia/Shanghai")), 860 | ) 861 | 862 | mocked_get_wordcloud = mocker.patch( 863 | "nonebot_plugin_wordcloud.get_wordcloud", 864 | return_value=FAKE_IMAGE, 865 | ) 866 | 867 | async with app.test_matcher(wordcloud_cmd) as ctx: 868 | adapter = get_adapter(AdapterV12) 869 | bot = ctx.create_bot( 870 | base=BotV12, 871 | adapter=adapter, 872 | auto_connect=False, 873 | platform="test", 874 | impl="test", 875 | ) 876 | event = fake_channel_message_event_v12(message=MessageV12("/我的今日词云")) 877 | 878 | ctx.receive_event(bot, event) 879 | should_send_saa( 880 | ctx, 881 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 882 | bot, 883 | event=event, 884 | at_sender=True, 885 | ) 886 | ctx.should_finished() 887 | 888 | mocked_datetime_now.assert_called_once_with() 889 | mocked_get_wordcloud.assert_called_once_with( 890 | ["v12-10:1-2"], 891 | "unknown_ob12-platform=qq-detail_type=channel-guild_id=10000-channel_id=100000", 892 | ) 893 | 894 | 895 | @pytest.mark.usefixtures("_message_record") 896 | async def test_today_wordcloud_qq_group_v12(app: App, mocker: MockerFixture): 897 | """测试 ob12 的 QQ群 今日词云""" 898 | from nonebot_plugin_saa import Image, MessageFactory 899 | 900 | from nonebot_plugin_wordcloud import wordcloud_cmd 901 | 902 | mocked_datetime_now = mocker.patch( 903 | "nonebot_plugin_wordcloud.get_datetime_now_with_timezone", 904 | return_value=datetime(2022, 1, 2, 23, tzinfo=ZoneInfo("Asia/Shanghai")), 905 | ) 906 | 907 | mocked_get_wordcloud = mocker.patch( 908 | "nonebot_plugin_wordcloud.get_wordcloud", 909 | return_value=FAKE_IMAGE, 910 | ) 911 | async with app.test_matcher(wordcloud_cmd) as ctx: 912 | adapter = get_adapter(AdapterV12) 913 | bot = ctx.create_bot( 914 | base=BotV12, 915 | adapter=adapter, 916 | auto_connect=False, 917 | platform="qq", 918 | impl="test", 919 | ) 920 | event = fake_group_message_event_v12(message=MessageV12("/今日词云")) 921 | 922 | ctx.receive_event(bot, event) 923 | should_send_saa( 924 | ctx, 925 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 926 | bot, 927 | event=event, 928 | ) 929 | ctx.should_finished() 930 | 931 | mocked_datetime_now.assert_called_once_with() 932 | mocked_get_wordcloud.assert_called_once_with( 933 | ["10:1-2", "11:1-2"], "qq_group-group_id=10000" 934 | ) 935 | 936 | 937 | @pytest.mark.usefixtures("_message_record") 938 | async def test_today_wordcloud_exclude_user_ids(app: App, mocker: MockerFixture): 939 | """测试今日词云,排除特定用户""" 940 | from nonebot_plugin_saa import Image, MessageFactory 941 | 942 | from nonebot_plugin_wordcloud import plugin_config, wordcloud_cmd 943 | 944 | mocker.patch.object(plugin_config, "wordcloud_exclude_user_ids", {"10"}) 945 | 946 | mocked_datetime_now = mocker.patch( 947 | "nonebot_plugin_wordcloud.get_datetime_now_with_timezone", 948 | return_value=datetime(2022, 1, 2, 23, tzinfo=ZoneInfo("Asia/Shanghai")), 949 | ) 950 | 951 | mocked_get_wordcloud = mocker.patch( 952 | "nonebot_plugin_wordcloud.get_wordcloud", 953 | return_value=FAKE_IMAGE, 954 | ) 955 | 956 | async with app.test_matcher(wordcloud_cmd) as ctx: 957 | adapter = get_adapter(Adapter) 958 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 959 | event = fake_group_message_event_v11(message=Message("/今日词云")) 960 | 961 | ctx.receive_event(bot, event) 962 | should_send_saa( 963 | ctx, 964 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 965 | bot, 966 | event=event, 967 | ) 968 | ctx.should_finished() 969 | 970 | mocked_datetime_now.assert_called_once_with() 971 | mocked_get_wordcloud.assert_called_once_with(["11:1-2"], "qq_group-group_id=10000") 972 | 973 | 974 | @pytest.mark.usefixtures("_message_record") 975 | async def test_today_wordcloud_reply_message(app: App, mocker: MockerFixture): 976 | """测试今日词云回复消息""" 977 | from nonebot_plugin_chatrecorder import get_messages_plain_text 978 | from nonebot_plugin_saa import Image, MessageFactory 979 | 980 | from nonebot_plugin_wordcloud import plugin_config, wordcloud_cmd 981 | 982 | mocker.patch.object(plugin_config, "wordcloud_reply_message", True) 983 | 984 | # 排除机器人自己的消息 985 | messages = await get_messages_plain_text() 986 | assert len(messages) == 9 987 | 988 | mocked_datetime_now = mocker.patch( 989 | "nonebot_plugin_wordcloud.get_datetime_now_with_timezone", 990 | return_value=datetime(2022, 1, 2, 23, tzinfo=ZoneInfo("Asia/Shanghai")), 991 | ) 992 | 993 | mocked_get_wordcloud = mocker.patch( 994 | "nonebot_plugin_wordcloud.get_wordcloud", 995 | return_value=FAKE_IMAGE, 996 | ) 997 | 998 | async with app.test_matcher(wordcloud_cmd) as ctx: 999 | adapter = get_adapter(Adapter) 1000 | bot = ctx.create_bot(base=Bot, adapter=adapter, auto_connect=False) 1001 | event = fake_group_message_event_v11(message=Message("/今日词云")) 1002 | 1003 | ctx.receive_event(bot, event) 1004 | should_send_saa( 1005 | ctx, 1006 | MessageFactory(Image(FAKE_IMAGE, "wordcloud.png")), 1007 | bot, 1008 | event=event, 1009 | reply=True, 1010 | ) 1011 | ctx.should_finished() 1012 | 1013 | mocked_datetime_now.assert_called_once_with() 1014 | mocked_get_wordcloud.assert_called_once_with( 1015 | ["10:1-2", "11:1-2"], "qq_group-group_id=10000" 1016 | ) 1017 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import TYPE_CHECKING 3 | 4 | if TYPE_CHECKING: 5 | from nonebot.adapters.onebot.v11 import GroupMessageEvent as GroupMessageEventV11 6 | from nonebot.adapters.onebot.v11 import ( 7 | PrivateMessageEvent as PrivateMessageEventV11, 8 | ) 9 | from nonebot.adapters.onebot.v12 import ( 10 | ChannelMessageEvent as ChannelMessageEventV12, 11 | ) 12 | from nonebot.adapters.onebot.v12 import GroupMessageEvent as GroupMessageEventV12 13 | from nonebot.adapters.onebot.v12 import ( 14 | PrivateMessageEvent as PrivateMessageEventV12, 15 | ) 16 | 17 | 18 | def fake_group_message_event_v11(**field) -> "GroupMessageEventV11": 19 | from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message 20 | from nonebot.adapters.onebot.v11.event import Sender 21 | 22 | fake_field = { 23 | "time": 1000000, 24 | "self_id": 1, 25 | "post_type": "message", 26 | "sub_type": "normal", 27 | "user_id": 10, 28 | "message_type": "group", 29 | "group_id": 10000, 30 | "message_id": 1, 31 | "message": Message("test"), 32 | "raw_message": "test", 33 | "font": 0, 34 | "sender": Sender( 35 | card="", 36 | nickname="test", 37 | role="member", 38 | ), 39 | "to_me": False, 40 | } 41 | fake_field.update(field) 42 | 43 | return GroupMessageEvent(**fake_field) 44 | 45 | 46 | def fake_private_message_event_v11(**field) -> "PrivateMessageEventV11": 47 | from nonebot.adapters.onebot.v11 import Message, PrivateMessageEvent 48 | from nonebot.adapters.onebot.v11.event import Sender 49 | 50 | fake_field = { 51 | "time": 1000000, 52 | "self_id": 1, 53 | "post_type": "message", 54 | "sub_type": "friend", 55 | "user_id": 10, 56 | "message_type": "private", 57 | "message_id": 1, 58 | "message": Message("test"), 59 | "raw_message": "test", 60 | "font": 0, 61 | "sender": Sender(nickname="test"), 62 | "to_me": False, 63 | } 64 | fake_field.update(field) 65 | 66 | return PrivateMessageEvent(**fake_field) 67 | 68 | 69 | def fake_group_message_event_v12(**field) -> "GroupMessageEventV12": 70 | from nonebot.adapters.onebot.v12 import GroupMessageEvent, Message 71 | from nonebot.adapters.onebot.v12.event import BotSelf 72 | 73 | fake_field = { 74 | "self": BotSelf(platform="qq", user_id="test"), 75 | "id": "1", 76 | "time": datetime.fromtimestamp(1000000), 77 | "type": "message", 78 | "detail_type": "group", 79 | "sub_type": "normal", 80 | "message_id": "10", 81 | "message": Message("test"), 82 | "original_message": Message("test"), 83 | "alt_message": "test", 84 | "user_id": "100", 85 | "group_id": "10000", 86 | "to_me": False, 87 | } 88 | fake_field.update(field) 89 | 90 | return GroupMessageEvent(**fake_field) 91 | 92 | 93 | def fake_private_message_event_v12(**field) -> "PrivateMessageEventV12": 94 | from nonebot.adapters.onebot.v12 import Message, PrivateMessageEvent 95 | from nonebot.adapters.onebot.v12.event import BotSelf 96 | 97 | fake_field = { 98 | "self": BotSelf(platform="qq", user_id="test"), 99 | "id": "1", 100 | "time": datetime.fromtimestamp(1000000), 101 | "type": "message", 102 | "detail_type": "private", 103 | "sub_type": "", 104 | "message_id": "10", 105 | "message": Message("test"), 106 | "original_message": Message("test"), 107 | "alt_message": "test", 108 | "user_id": "100", 109 | "to_me": False, 110 | } 111 | fake_field.update(field) 112 | 113 | return PrivateMessageEvent(**fake_field) 114 | 115 | 116 | def fake_channel_message_event_v12(**field) -> "ChannelMessageEventV12": 117 | from nonebot.adapters.onebot.v12 import ChannelMessageEvent, Message 118 | from nonebot.adapters.onebot.v12.event import BotSelf 119 | 120 | fake_field = { 121 | "self": BotSelf(platform="qq", user_id="test"), 122 | "id": "1", 123 | "time": datetime.fromtimestamp(1000000), 124 | "type": "message", 125 | "detail_type": "channel", 126 | "sub_type": "", 127 | "message_id": "10", 128 | "message": Message("test"), 129 | "original_message": Message("test"), 130 | "alt_message": "test", 131 | "user_id": "10", 132 | "guild_id": "10000", 133 | "channel_id": "100000", 134 | "to_me": False, 135 | } 136 | fake_field.update(field) 137 | 138 | return ChannelMessageEvent(**fake_field) 139 | --------------------------------------------------------------------------------