├── .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 |
5 |
6 |
7 |
8 |
9 | # NoneBot Plugin WordCloud
10 |
11 | _✨ NoneBot 词云插件 ✨_
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
27 |
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 |
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 |
--------------------------------------------------------------------------------