├── .github
└── workflows
│ └── python-publish.yml
├── .gitignore
├── README.md
├── batch.md
├── nonebot_plugin_quote
├── __init__.py
├── config.py
├── make_image.py
├── pilmoji
│ ├── LICENSE
│ ├── README.md
│ ├── __init__.py
│ ├── core.py
│ ├── helpers.py
│ └── source.py
└── task.py
├── pyproject.toml
└── screenshot
├── auto_generate.png
├── auto_record.jpg
├── data.jpg
├── delete.jpg
├── non.jpg
├── random.jpg
├── select.jpg
├── tag.jpg
├── upload.jpg
└── usetag.jpg
/.github/workflows/python-publish.yml:
--------------------------------------------------------------------------------
1 | # This workflow will upload a Python Package to PyPI when a release is created
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
3 |
4 | # This workflow uses actions that are not certified by GitHub.
5 | # They are provided by a third-party and are governed by
6 | # separate terms of service, privacy policy, and support
7 | # documentation.
8 |
9 | name: Upload Python Package
10 |
11 | on:
12 | release:
13 | types: [published]
14 |
15 | permissions:
16 | contents: read
17 |
18 | jobs:
19 | release-build:
20 | runs-on: ubuntu-latest
21 |
22 | steps:
23 | - uses: actions/checkout@v4
24 |
25 | - uses: actions/setup-python@v5
26 | with:
27 | python-version: "3.x"
28 |
29 | - name: Build release distributions
30 | run: |
31 | # NOTE: put your own distribution build steps here.
32 | python -m pip install build
33 | python -m build
34 | - name: Upload distributions
35 | uses: actions/upload-artifact@v4
36 | with:
37 | name: release-dists
38 | path: dist/
39 |
40 | pypi-publish:
41 | runs-on: ubuntu-latest
42 | needs:
43 | - release-build
44 | permissions:
45 | # IMPORTANT: this permission is mandatory for trusted publishing
46 | id-token: write
47 |
48 | # Dedicated environments with protections for publishing are strongly recommended.
49 | # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules
50 | environment:
51 | name: pypi
52 | # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
53 | url: https://pypi.org/project/nonebot-plugin-quote/
54 | #
55 | # ALTERNATIVE: if your GitHub Release name is the PyPI project version string
56 | # ALTERNATIVE: exactly, uncomment the following line instead:
57 | # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }}
58 |
59 | steps:
60 | - name: Retrieve release distributions
61 | uses: actions/download-artifact@v4
62 | with:
63 | name: release-dists
64 | path: dist/
65 |
66 | - name: Publish release distributions to PyPI
67 | uses: pypa/gh-action-pypi-publish@release/v1
68 | with:
69 | packages-dir: dist/
70 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Created by https://www.toptal.com/developers/gitignore/api/linux,macos,python,windows,jetbrains,visualstudiocode
3 | # Edit at https://www.toptal.com/developers/gitignore?templates=linux,macos,python,windows,jetbrains,visualstudiocode
4 |
5 | ### JetBrains ###
6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
8 |
9 | # User-specific stuff
10 | .idea/**/workspace.xml
11 | .idea/**/tasks.xml
12 | .idea/**/usage.statistics.xml
13 | .idea/**/dictionaries
14 | .idea/**/shelf
15 |
16 | # AWS User-specific
17 | .idea/**/aws.xml
18 |
19 | # Generated files
20 | .idea/**/contentModel.xml
21 |
22 | # Sensitive or high-churn files
23 | .idea/**/dataSources/
24 | .idea/**/dataSources.ids
25 | .idea/**/dataSources.local.xml
26 | .idea/**/sqlDataSources.xml
27 | .idea/**/dynamic.xml
28 | .idea/**/uiDesigner.xml
29 | .idea/**/dbnavigator.xml
30 |
31 | # Gradle
32 | .idea/**/gradle.xml
33 | .idea/**/libraries
34 |
35 | # Gradle and Maven with auto-import
36 | # When using Gradle or Maven with auto-import, you should exclude module files,
37 | # since they will be recreated, and may cause churn. Uncomment if using
38 | # auto-import.
39 | # .idea/artifacts
40 | # .idea/compiler.xml
41 | # .idea/jarRepositories.xml
42 | # .idea/modules.xml
43 | # .idea/*.iml
44 | # .idea/modules
45 | # *.iml
46 | # *.ipr
47 |
48 | # CMake
49 | cmake-build-*/
50 |
51 | # Mongo Explorer plugin
52 | .idea/**/mongoSettings.xml
53 |
54 | # File-based project format
55 | *.iws
56 |
57 | # IntelliJ
58 | out/
59 |
60 | # mpeltonen/sbt-idea plugin
61 | .idea_modules/
62 |
63 | # JIRA plugin
64 | atlassian-ide-plugin.xml
65 |
66 | # Cursive Clojure plugin
67 | .idea/replstate.xml
68 |
69 | # SonarLint plugin
70 | .idea/sonarlint/
71 |
72 | # Crashlytics plugin (for Android Studio and IntelliJ)
73 | com_crashlytics_export_strings.xml
74 | crashlytics.properties
75 | crashlytics-build.properties
76 | fabric.properties
77 |
78 | # Editor-based Rest Client
79 | .idea/httpRequests
80 |
81 | # Android studio 3.1+ serialized cache file
82 | .idea/caches/build_file_checksums.ser
83 |
84 | ### JetBrains Patch ###
85 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
86 |
87 | # *.iml
88 | # modules.xml
89 | # .idea/misc.xml
90 | # *.ipr
91 |
92 | # Sonarlint plugin
93 | # https://plugins.jetbrains.com/plugin/7973-sonarlint
94 | .idea/**/sonarlint/
95 |
96 | # SonarQube Plugin
97 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
98 | .idea/**/sonarIssues.xml
99 |
100 | # Markdown Navigator plugin
101 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
102 | .idea/**/markdown-navigator.xml
103 | .idea/**/markdown-navigator-enh.xml
104 | .idea/**/markdown-navigator/
105 |
106 | # Cache file creation bug
107 | # See https://youtrack.jetbrains.com/issue/JBR-2257
108 | .idea/$CACHE_FILE$
109 |
110 | # CodeStream plugin
111 | # https://plugins.jetbrains.com/plugin/12206-codestream
112 | .idea/codestream.xml
113 |
114 | ### Linux ###
115 | *~
116 |
117 | # temporary files which can be created if a process still has a handle open of a deleted file
118 | .fuse_hidden*
119 |
120 | # KDE directory preferences
121 | .directory
122 |
123 | # Linux trash folder which might appear on any partition or disk
124 | .Trash-*
125 |
126 | # .nfs files are created when an open file is removed but is still being accessed
127 | .nfs*
128 |
129 | ### macOS ###
130 | # General
131 | .DS_Store
132 | .AppleDouble
133 | .LSOverride
134 |
135 | # Icon must end with two \r
136 | Icon
137 |
138 |
139 | # Thumbnails
140 | ._*
141 |
142 | # Files that might appear in the root of a volume
143 | .DocumentRevisions-V100
144 | .fseventsd
145 | .Spotlight-V100
146 | .TemporaryItems
147 | .Trashes
148 | .VolumeIcon.icns
149 | .com.apple.timemachine.donotpresent
150 |
151 | # Directories potentially created on remote AFP share
152 | .AppleDB
153 | .AppleDesktop
154 | Network Trash Folder
155 | Temporary Items
156 | .apdisk
157 |
158 | ### Python ###
159 | # Byte-compiled / optimized / DLL files
160 | __pycache__/
161 | *.py[cod]
162 | *$py.class
163 |
164 | # C extensions
165 | *.so
166 |
167 | # Distribution / packaging
168 | .Python
169 | build/
170 | develop-eggs/
171 | dist/
172 | downloads/
173 | eggs/
174 | .eggs/
175 | lib/
176 | lib64/
177 | parts/
178 | sdist/
179 | var/
180 | wheels/
181 | share/python-wheels/
182 | *.egg-info/
183 | .installed.cfg
184 | *.egg
185 | MANIFEST
186 |
187 | # PyInstaller
188 | # Usually these files are written by a python script from a template
189 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
190 | *.manifest
191 | *.spec
192 |
193 | # Installer logs
194 | pip-log.txt
195 | pip-delete-this-directory.txt
196 |
197 | # Unit test / coverage reports
198 | htmlcov/
199 | .tox/
200 | .nox/
201 | .coverage
202 | .coverage.*
203 | .cache
204 | nosetests.xml
205 | coverage.xml
206 | *.cover
207 | *.py,cover
208 | .hypothesis/
209 | .pytest_cache/
210 | cover/
211 |
212 | # Translations
213 | *.mo
214 | *.pot
215 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |

5 |
6 |
7 |
8 |
9 | # nonebot-plugin-quote
10 |
11 | _✨ QQ群聊 语录库 ✨_
12 |
13 | 🧬 支持OCR识别,关键词搜索 | 一起记录群友的逆天言论吧!🎉
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | ## 📖 介绍
26 |
27 | 一款适用于QQ群聊天的语录库插件。
28 |
29 | - [x] 上传聊天截图
30 | - [x] 随机投放聊天语录
31 | - [x] 根据关键词投放聊天语录
32 | - [x] 支持白名单内用户删除语录
33 | - [x] 支持为指定语录增删标签
34 | - [x] ~~[批量处理已有聊天截图(测试功能)](https://github.com/RongRongJi/nonebot_plugin_quote/blob/main/batch.md) (版本更新,该功能暂时不可用,等后续更新)~~
35 |
36 | 你的star是对我最好的支持!
37 |
38 | 交流QQ群: 580407499
39 |
40 | ## 🎉 使用
41 |
42 | ### 上传
43 |
44 | 以**上传**指令回复图片消息,即可直接将图片上传至语录库中。
45 |
46 |
47 |
48 |
49 | ### 随机发送语录
50 |
51 | @机器人,发送**语录**指令,机器人将从语录库中随机挑选一条语录发送。
52 |
53 |
54 |
55 | ### 关键词检索语录
56 |
57 | @机器人,发送**语录**+关键词指令,机器人将从语录库中进行查找。若有匹配项,将从匹配项中随机一条发送;若无匹配项,将从整个语录库中随机挑选一条发送。
58 |
59 |
60 |
61 |
62 | ### 删除语录
63 |
64 | 回复机器人发出的语录,发送**删除**指令,机器人将执行删除操作。(该操作只允许设置的白名单用户进行,如何设置白名单请看下方配置)
65 |
66 |
67 |
68 | ### 增加/删除标签
69 |
70 | 回复语录图片,发送**addtag**+标签(addtag后需加空格,可以多个标签,每个标签之间用空格分隔),为指定语录增加额外标签。
71 |
72 | 回复语录图片,发送**deltag**+标签(deltag后需加空格,可以多个标签,每个标签之间用空格分隔),为指定语录删除不需要的标签。
73 |
74 |
75 |
76 | ### 指定标签检索语录
77 |
78 | @机器人,发送**语录**+#号+标签,将从语录库中对指定标签进行查找。加#号后,将只对#号后的完整的词进行查找;不加#号会进行分词。
79 |
80 |
81 |
82 | ### 生成语录式图片
83 |
84 | 在配置好中文字体路径后,以“命令前缀+**生成**”,回复群内任意一句话,即可生成如下语录体图片,**不录入语录库和本地保存**,支持emoji渲染,推荐使用等宽黑体(例如[更纱黑体](https://github.com/be5invis/Iosevka))以达到最好效果。
85 |
86 |
87 |
88 | ### 上传语录式图片
89 |
90 | 在配置好中文字体路径后,以“命令前缀+**记录**”,回复群内任意一句话,即可生成如下语录体图片,**录入语录库和本地保存**,支持emoji渲染,推荐使用等宽黑体(例如[更纱黑体](https://github.com/be5invis/Iosevka))以达到最好效果。
91 |
92 |
93 |
94 | ### 详细命令
95 |
96 | 默认配置下,@机器人加指令即可。
97 |
98 | | 指令 | 需要@ | 范围 | 说明 |
99 | |:-----:|:----:|:------:|:-----------:|
100 | | 回复图片+上传 | 可选 | 群聊 | 上传图片至语录库 |
101 | | 语录 + 关键词(可选) | 可选 | 群聊 | 根据关键词返回一个符合要求的图片, 没有关键词时随机返回 |
102 | | 语录 + #标签 | 可选 | 群聊 | 根据标签返回一个符合要求的图片, 没有关键词时随机返回 |
103 | | 回复机器人 + 删除 | 可选 | 群聊 | 删除该条语录 |
104 | | 语句中包含语录 | 是 | 群聊 | 对如何使用语录进行说明 |
105 | | 回复机器人 + addtag + 标签(addtag和标签之间需要空格)| 可选 | 群聊 | 为该条语录增加额外标签 |
106 | | 回复机器人 + deltag + 标签(deltag和标签之间需要空格)| 可选 | 群聊 | 为该条语录删除指定标签 |
107 | | 回复机器人 + alltag| 可选 | 群聊 | 查看该条语录所有标签 |
108 | | 回复消息+记录 | 否 | 群聊 | 为回复消息生成语录式图片并**记录至语录库**,不能上传自己的语录 |
109 | | 回复消息+生成 | 否 | 群聊 | 为回复消息生成语录式图片,**不在本地存储** |
110 |
111 | ## 💿 安装
112 |
113 | ### 下载
114 |
115 | 1. 通过包管理器安装,可以通过nb,pip,或者poetry等方式安装,以pip为例
116 |
117 | ```
118 | pip install nonebot-plugin-quote -U
119 | ```
120 |
121 | 2. 手动安装
122 |
123 | ```
124 | git clone https://github.com/RongRongJi/nonebot_plugin_quote.git
125 | ```
126 |
127 | 3. 使用nb-cli安装
128 |
129 | ```
130 | nb plugin install nonebot-plugin-quote
131 | ```
132 |
133 | ## ⚙️ 配置
134 |
135 | 在 nonebot2 项目的 `.env` 文件中添加下表中的必填配置
136 |
137 | | 配置项 | 必填 | 默认值 | 说明 |
138 | |:-----:|:----:|:----:|:----:|
139 | | FONT_PATH | 是 | None | 必要的语录中文字体文件路径(若不填,部分功能无法使用) |
140 | | AUTHOR_FONT_PATH | 是 | None | 必要的作者中文字体文件路径(若不填,部分功能无法使用) |
141 | | QUOTE_PATH | 否 | ./data | 可选,默认使用'./data' |
142 | | RECORD_PATH | 否 | 'record.json' | 必要的json文件路径, 示例"/data/record.json" |
143 | | INVERTED_INDEX_PATH | 否 | 'inverted_index.json' | 必要的json文件路径, 示例"/data/inverted_index.json" |
144 | | QUOTE_SUPERUSER | 否 | 空字典 | 白名单字典(分群) |
145 | | GLOBAL_SUPERUSER | 否 | 空数组 | 全局管理员(可以删除每个群的语录) |
146 | | QUOTE_NEEDAT | 否 | True | 是否需要at机器人(开启上传通道必须at) |
147 | | QUOTE_STARTCMD | 否 | '' | 增加指令前缀 |
148 |
149 | `RECORD_PATH`和`INVERTED_INDEX_PATH`只需要配置,无需创建文件;若不配置`RECORD_PATH`和`INVERTED_INDEX_PATH`,将会自动在项目根目录下创建两个json文件。
150 |
151 | `QUOTE_SUPERUSER`的示例如下:
152 |
153 | ```json
154 | {"群号1":["语录管理员qq号","语录管理员qq号"],"群号2":["语录管理员qq号"]}
155 | ```
156 |
157 | `GLOBAL_SUPERUSER`的示例如下:
158 |
159 | ```json
160 | ["全局管理员qq号"]
161 | ```
162 |
163 | **完整的`.env`配置可以参考以下内容**
164 |
165 | ```
166 | # linux环境下路径
167 | RECORD_PATH=/home/your_name/your_path/record.json
168 | INVERTED_INDEX_PATH=/home/your_name/your_path/inverted_index.json
169 | FONT_PATH=/home/your_name/your_path/font.ttf
170 | AUTHOR_FONT_PATH=/home/your_name/your_path/author_font.ttf
171 |
172 | # Windows环境下路径
173 | RECORD_PATH=D:\your_path\record.json
174 | INVERTED_INDEX_PATH=D:\your_path\inverted_index.json
175 | FONT_PATH=D:\your_path\font.ttf
176 | AUTHOR_FONT_PATH=D:\your_path\author_font.ttf
177 |
178 | QUOTE_PATH='./data'
179 | QUOTE_SUPERUSER={"12345":["123456"],"54321":["123456","654321"]}
180 | GLOBAL_SUPERUSER=["6666666"]
181 | QUOTE_NEEDAT=True
182 | QUOTE_STARTCMD=""
183 | ```
184 |
185 | 随后,在项目的`pyproject.toml`或`bot.py`中加上如下代码,加载插件(根据版本而定)
186 |
187 | `pyproject.toml`中添加
188 |
189 | ```
190 | # pip install的填这个
191 | plugins = ["nonebot_plugin_quote"]
192 |
193 | # 手动安装的填这个
194 | plugin_dirs = ["nonebot_plugin_quote"]
195 | ```
196 |
197 | 或
198 |
199 | `bot.py`中添加
200 |
201 | ```
202 | # pip install的填这个
203 | nonebot.load_plugin("nonebot_plugin_quote")
204 |
205 | # 手动安装的填这个
206 | nonebot.load_plugins("src/plugins", "nonebot_plugin_quote")
207 | ```
208 |
209 | ## Change Log
210 |
211 |
212 | 点击展开更新日志
213 |
214 | ### v0.2.0 (2023/3/20)
215 |
216 | - 删除了对Docker OCR的依赖,现在无需使用Docker,直接安装插件运行即可
217 | - 增加了删除语录功能,只有在白名单中的用户拥有删除权限
218 | - 增加了部分gif的OCR能力,但目前并不准确
219 |
220 | ### v0.2.2 (2023/3/21)
221 |
222 | - 增加了全局管理员的设置,全局管理员拥有删除每个群语录库的权限
223 | - 修复了一个关于上传后缀名不匹配的bug
224 |
225 | ### v0.2.3 (2023/3/22)
226 |
227 | - 在OCR识别文字后增加了换行长文字与不同文字段的识别,使分词更加准确
228 |
229 | ### v0.3.0 (2023/3/28)
230 |
231 | - 新增标签功能,包括针对一条语录【新增标签】、【删除标签】、【查看全部标签】
232 | - 增加了初始文件的默认路径,不再需要用户手动创建文件
233 | - IO统一为UTF-8
234 |
235 | ### v0.3.2 (2023/3/29)
236 |
237 | - 增加了是否需要at机器人的选项
238 | - 增加了指令前缀
239 |
240 | ### v0.3.4 (2023/4/2)
241 |
242 | - 增加批量上传语录功能(试验版)
243 |
244 | ### v0.3.5 (2023/4/14)
245 |
246 | - 修改了匹配策略,使不同协议下的消息格式都可以匹配
247 | - 增加批量备份语录功能(试验版)
248 |
249 | ### v0.3.6 (2024/6/2)
250 |
251 | - 更新了匹配规则,更改了ntQQ下图片无法识别的问题。
252 | - 原作者在摆(大概)故V0.3.6之后版本暂时由[Hanserprpr](https://github.com/Hanserprpr)维护
253 |
254 | ### v0.3.6.1 (2024/6/5)
255 |
256 | - 更改ocr方式,从go-cqhttp自带ocr变更为使用PaddleOCR,增加llBot支持。go-cqhttp用户请注意env文件QUOTE_PATH_NEW配置正确。
257 | - 首次使用会下载模型,时间可能较长(<1min),切记关闭代理。
258 | - [TODO]适配Lagrange框架。
259 |
260 | ### v0.3.7 (2024/11/7)
261 |
262 | - 更改图片发送和匹配方式
263 | - 自定义图片下载路径
264 | - 修正tag问题
265 | - 修复pydantic错误
266 | - 主流框架适配
267 |
268 | ### v0.3.8 (2024/11/10)
269 |
270 | - 由于 Lagrange 未实现 get_image,更改图片下载方式以适配 Lagrange。
271 |
272 | ### v0.3.9 (2025/2/11)
273 |
274 | - 增加自动生成、记录语录图片功能,感谢[Pigz2538](https://github.com/pigz2538)提交
275 |
276 | ### v0.4.0 (2025/3/12)
277 |
278 | - 将上传功能进行改版,直接回复图片上传语录,感谢[Pigz2538](https://github.com/pigz2538)提交
279 |
280 | ### v0.4.1 (2025/5/28)
281 |
282 | - 解决event.reply.sender.card返回None而非空字符串的识别错误
283 | - 由于最新的Pillow依赖移除了部分方法,因此本项目限定Pillow版本
284 |
285 | ### v0.4.2 (2025/5/29)
286 |
287 | - 适配lagrange框架
288 |
289 |
290 |
291 | ## 🎉 鸣谢
292 |
293 | - [NoneBot2](https://github.com/nonebot/nonebot2):本插件使用的开发框架。
294 | - [go-cqhttp](https://github.com/Mrs4s/go-cqhttp):稳定完善的 CQHTTP 实现。
295 |
296 | ## 开发者
297 |
298 | [](https://github.com/RongRongJi/nonebot_plugin_quote/graphs/contributors)
299 |
--------------------------------------------------------------------------------
/batch.md:
--------------------------------------------------------------------------------
1 | ## 🎉 批量导入
2 |
3 | ### 适配版本
4 |
5 | v0.3.4+
6 |
7 | ### 功能简介
8 |
9 | 如果您的个人电脑上保存有许多群聊天记录截图,想要直接接入本机器人插件,成为群语录库,**批量导入**可以帮助你实现这一功能。
10 |
11 | ### 使用方法
12 |
13 | 1. 批量导入功能只有超级管理员用户才能使用
14 |
15 | 超级管理员用户需要在 nonebot2 项目的 `.env` 文件中添加配置
16 |
17 | ```
18 | GLOBAL_SUPERUSER=["6666666"]
19 | ```
20 |
21 | 2. 受私聊和群聊API方法不同的限制,该功能只能走**群聊**。建议超级管理员创建一个新的群聊,只拉入机器人,再进行以下操作。
22 |
23 |
24 | 3. 在群聊窗口中直接一次性输入下面内容,即可进行开启批量通道。
25 |
26 | ```
27 | batch_upload
28 | qqgroup=123456
29 | your_path=/home/name/project/data
30 | gocq_path=/home/name/gocq/data/cache
31 | tags=aaa bbb ccc
32 | ```
33 |
34 | 上述内容解释如下:
35 |
36 | 向群号为123456的qq群批量上传语录。将保存在/home/name/project/data/目录下的所有聊天截图上传,你所使用的go-cqhttp下的data/cache目录为/home/name/gocq/data/cache/。这一批截图除了进行OCR自动识别标签外,还将全部额外附上aaa、bbb、ccc三个标签(每个标签用空格分开)。
37 |
38 | ## 🎉 批量备份
39 |
40 | ### 适配版本
41 |
42 | v0.3.5+
43 |
44 | ### 功能简介
45 |
46 | 将目前服务中所保存的语录图统一备份到指定目录下
47 |
48 | ### 使用方法
49 |
50 | 1. 批量导入功能只有超级管理员用户才能使用
51 |
52 | 超级管理员用户需要在 nonebot2 项目的 `.env` 文件中添加配置
53 |
54 | ```
55 | GLOBAL_SUPERUSER=["6666666"]
56 | ```
57 |
58 | 2. 在窗口中直接一次性输入下面内容,即可进行开启批量通道。
59 |
60 | ```
61 | batch_copy
62 | your_path=/home/name/backup/data
63 | gocq_path=/home/name/gocq/data/cache
64 | ```
65 |
66 | 上述内容解释如下:
67 |
68 | 将当前所有聊天截图保存在/home/name/backup/data/目录下,你所使用的go-cqhttp下的data/cache目录为/home/name/gocq/data/cache/。
69 |
70 | ## 特别注意
71 |
72 | *该功能目前处于测试阶段,欢迎反馈
--------------------------------------------------------------------------------
/nonebot_plugin_quote/__init__.py:
--------------------------------------------------------------------------------
1 | from nonebot import on_command, on_keyword, on_startswith, get_driver, on_regex
2 | from nonebot.rule import to_me
3 | from nonebot.adapters import Message
4 | from nonebot.params import Arg, ArgPlainText, CommandArg
5 | from nonebot.adapters.onebot.v11 import Bot, Event, Message, MessageEvent, PrivateMessageEvent, MessageSegment, exception
6 | from nonebot.typing import T_State
7 | from nonebot.plugin import PluginMetadata
8 | import re
9 | import json
10 | import random
11 | import os
12 | import shutil
13 | from .task import offer, query, delete, handle_ocr_text, inverted2forward, findAlltag, addTag, delTag
14 | from .task import copy_images_files
15 | from .config import Config, check_font
16 | from nonebot.log import logger
17 | import time
18 | from paddleocr import PaddleOCR
19 | from PIL import Image
20 | import io
21 | import httpx
22 | import hashlib
23 | import uuid
24 | from .make_image import generate_quote_image
25 |
26 | # v0.4.2
27 |
28 | __plugin_meta__ = PluginMetadata(
29 | name='群聊语录库',
30 | description='一款QQ群语录库——支持上传聊天截图为语录,随机投放语录,关键词搜索语录精准投放',
31 | usage='语录 上传 删除',
32 | type="application",
33 | homepage="https://github.com/RongRongJi/nonebot_plugin_quote",
34 | config=Config,
35 | supported_adapters={"~onebot.v11"},
36 | extra={
37 | 'author': 'RongRongJi',
38 | 'version': 'v0.4.2',
39 | },
40 | )
41 |
42 | plugin_config = Config.parse_obj(get_driver().config.dict())
43 |
44 | need_at = {}
45 | if (plugin_config.quote_needat):
46 | need_at['rule'] = to_me()
47 |
48 | record_dict = {}
49 | inverted_index = {}
50 | quote_path = plugin_config.quote_path
51 | font_path = plugin_config.font_path
52 | author_font_path = plugin_config.author_font_path
53 |
54 | # 判断参数配置情况
55 | if quote_path == 'quote':
56 | quote_path = './data'
57 | logger.warning('未配置quote文件路径,使用默认配置: ./data')
58 | os.makedirs(quote_path, exist_ok=True)
59 |
60 | if not check_font(font_path, author_font_path):
61 | logger.warning('未配置字体路径,部分功能无法使用')
62 |
63 | # 首次运行时导入表
64 | try:
65 | with open(plugin_config.record_path, 'r', encoding='UTF-8') as fr:
66 | record_dict = json.load(fr)
67 |
68 | with open(plugin_config.inverted_index_path, 'r', encoding='UTF-8') as fi:
69 | inverted_index = json.load(fi)
70 | logger.info('nonebot_plugin_quote路径配置成功')
71 | except Exception as e:
72 | with open(plugin_config.record_path, 'w', encoding='UTF-8') as f:
73 | json.dump(record_dict, f, indent=2, separators=(',', ': '), ensure_ascii=False)
74 |
75 | with open(plugin_config.inverted_index_path, 'w', encoding='UTF-8') as fc:
76 | json.dump(inverted_index, fc, indent=2, separators=(',', ': '), ensure_ascii=False)
77 | logger.warning('已创建json文件')
78 |
79 |
80 | forward_index = inverted2forward(inverted_index)
81 |
82 |
83 | # 回复信息处理
84 | async def reply_handle(bot, errMsg, raw_message, groupNum, user_id, listener):
85 | print(raw_message)
86 | if 'reply' not in raw_message:
87 | await bot.call_api('send_group_msg', **{
88 | 'group_id': int(groupNum),
89 | 'message': '[CQ:at,qq=' + user_id + ']' + errMsg
90 | })
91 | await listener.finish()
92 |
93 | # reply之后第一个等号,到数字后第一个非数字
94 | idx = raw_message.find('reply')
95 | reply_id = ''
96 | for i in range(idx, len(raw_message)):
97 | if raw_message[i] == '=':
98 | idx = i
99 | break
100 | for i in range(idx + 1, len(raw_message)):
101 | if raw_message[i] != '-' and not raw_message[i].isdigit():
102 | break
103 | reply_id += raw_message[i]
104 |
105 | resp = await bot.get_msg(message_id=reply_id)
106 | img_msg = resp['message']
107 |
108 | # 检查消息中是否包含图片
109 | image_found = False
110 | for msg_part in img_msg:
111 | if msg_part['type'] == 'image':
112 | image_found = True
113 | file_name = msg_part['data']['file']
114 | if file_name.startswith('http'):
115 | raw_filename = msg_part['data'].get('filename', 'image.jpg').upper()
116 | name, _ = os.path.splitext(raw_filename)
117 | file_name = name + ".png"
118 | break
119 | image_info = await bot.call_api('get_image', file=file_name)
120 | file_name = os.path.basename(image_info['file'])
121 | break
122 |
123 | if not image_found:
124 | await bot.send_msg(group_id=int(groupNum), message=MessageSegment.at(user_id) + errMsg)
125 | await listener.finish()
126 |
127 | return file_name
128 |
129 | # 语录库
130 |
131 | save_img = on_regex(pattern="^{}上传$".format(re.escape(plugin_config.quote_startcmd)), **need_at)
132 |
133 | @save_img.handle()
134 | async def save_img_handle(bot: Bot, event: MessageEvent, state: T_State):
135 |
136 | session_id = event.get_session_id()
137 | message_id = event.message_id
138 |
139 | global inverted_index
140 | global record_dict
141 | global forward_index
142 |
143 | print(event.reply.message)
144 | if event.reply:
145 | raw_message = str(event.reply.message)
146 | match = re.search(r'file=([^,]+)', raw_message)
147 | if match:
148 | file_name = match.group(1).strip('"\'')
149 | else:
150 | await make_record.finish("未检测到图片,请回复所需上传的图片消息来上传语录")
151 | else:
152 | await make_record.finish("请回复所需上传的图片消息来上传语录")
153 |
154 | try:
155 | resp = await bot.call_api('get_image', **{'file': file_name})
156 | image_path = resp['file']
157 | shutil.copy(image_path, os.path.join(quote_path, os.path.basename(image_path)))
158 |
159 | except Exception as e:
160 | logger.warning(f"bot.call_api 失败,可能在使用Lagrange,使用 httpx 进行下载: {e}")
161 | image_url = file_name
162 | match = re.search(r'filename=([^,]+)', raw_message)
163 | file_name = match.group(1).strip('"\'')
164 | async with httpx.AsyncClient() as client:
165 | image_url = image_url.replace('&', '&')
166 | response = await client.get(image_url)
167 | if response.status_code == 200:
168 | image_path = os.path.join(quote_path, file_name)
169 | with open(image_path, "wb") as f:
170 | f.write(response.content)
171 | resp = {"file": image_path}
172 | else:
173 | raise Exception("httpx 下载失败")
174 |
175 | image_path = os.path.abspath(os.path.join(quote_path, os.path.basename(image_path)))
176 | logger.info(f"图片已保存到 {image_path}")
177 | # OCR分词
178 | # 初始化PaddleOCR
179 | ocr = PaddleOCR(use_angle_cls=True, lang='ch')
180 | try:
181 | # 使用PaddleOCR进行OCR识别
182 | ocr_result = ocr.ocr(image_path, cls=True)
183 | # 处理OCR识别结果
184 | ocr_content = ''
185 | for line in ocr_result:
186 | for word in line:
187 | ocr_content += word[1][0] + ' '
188 | except Exception as e:
189 | ocr_content = ''
190 | print(f"OCR识别失败: {e}")
191 |
192 |
193 | if 'group' in session_id:
194 | tmpList = session_id.split('_')
195 | groupNum = tmpList[1]
196 |
197 | inverted_index, forward_index = offer(groupNum, image_path, ocr_content, inverted_index, forward_index)
198 |
199 | if groupNum not in record_dict:
200 | record_dict[groupNum] = [image_path]
201 | else:
202 | if image_path not in record_dict[groupNum]:
203 | record_dict[groupNum].append(image_path)
204 |
205 |
206 | with open(plugin_config.record_path, 'w', encoding='UTF-8') as f:
207 | json.dump(record_dict, f, indent=2, separators=(',', ': '), ensure_ascii=False)
208 |
209 | with open(plugin_config.inverted_index_path, 'w', encoding='UTF-8') as fc:
210 | json.dump(inverted_index, fc, indent=2, separators=(',', ': '), ensure_ascii=False)
211 |
212 | await bot.call_api('send_group_msg', **{
213 | 'group_id': int(groupNum),
214 | 'message': MessageSegment.reply(message_id) + '保存成功'
215 | })
216 |
217 |
218 | record_pool = on_startswith('{}语录'.format(plugin_config.quote_startcmd), priority=2, block=True, **need_at)
219 |
220 | @record_pool.handle()
221 | async def record_pool_handle(bot: Bot, event: Event, state: T_State):
222 |
223 | session_id = event.get_session_id()
224 | user_id = str(event.get_user_id())
225 |
226 | global inverted_index
227 | global record_dict
228 |
229 | if 'group' in session_id:
230 |
231 | search_info = str(event.get_message()).strip()
232 | search_info = search_info.replace('{}语录'.format(plugin_config.quote_startcmd), '').replace(' ', '')
233 |
234 | tmpList = session_id.split('_')
235 | groupNum = tmpList[1]
236 |
237 | if search_info == '':
238 | if groupNum not in record_dict:
239 | msg = '当前无语录库'
240 | else:
241 | length = len(record_dict[groupNum])
242 | idx = random.randint(0, length - 1)
243 | msg = MessageSegment.image(file=record_dict[groupNum][idx])
244 | else:
245 | ret = query(search_info, groupNum, inverted_index)
246 |
247 | if ret['status'] == -1:
248 | msg = '当前无语录库'
249 | elif ret['status'] == 2:
250 | if groupNum not in record_dict:
251 | msg = '当前无语录库'
252 | else:
253 | length = len(record_dict[groupNum])
254 | idx = random.randint(0, length - 1)
255 | msg = '当前查询无结果, 为您随机发送。'
256 | msg_segment = MessageSegment.image(file=record_dict[groupNum][idx])
257 | msg = msg + msg_segment
258 | elif ret['status'] == 1:
259 | msg = MessageSegment.image(file=ret['msg'])
260 | else:
261 | msg = ret.text
262 |
263 | response = await bot.call_api('send_group_msg', **{
264 | 'group_id': int(groupNum),
265 | 'message': msg
266 | })
267 | await record_pool.finish()
268 |
269 |
270 | record_help = on_keyword({"语录"}, priority=10, rule=to_me())
271 |
272 | @record_help.handle()
273 | async def record_help_handle(bot: Bot, event: Event, state: T_State):
274 |
275 | session_id = event.get_session_id()
276 | user_id = str(event.get_user_id())
277 | raw_msg = str(event.get_message())
278 | if '怎么用' not in raw_msg and '如何' not in raw_msg:
279 | await record_help.finish()
280 |
281 | msg = '''您可以通过回复指定图片, 发送【上传】指令上传语录。您也可以直接发送【语录】指令, 我将随机返回一条语录。'''
282 |
283 | if 'group' in session_id:
284 | tmpList = session_id.split('_')
285 | groupNum = tmpList[1]
286 |
287 | await bot.call_api('send_group_msg', **{
288 | 'group_id': int(groupNum),
289 | 'message': MessageSegment.at(user_id) + msg
290 | })
291 |
292 | await record_help.finish()
293 |
294 |
295 | delete_record = on_command('{}删除'.format(plugin_config.quote_startcmd), aliases={'delete'}, **need_at)
296 |
297 | @delete_record.handle()
298 | async def delete_record_handle(bot: Bot, event: Event, state: T_State):
299 |
300 | global inverted_index
301 | global record_dict
302 | global forward_index
303 |
304 | session_id = event.get_session_id()
305 | user_id = str(event.get_user_id())
306 |
307 | if 'group' not in session_id:
308 | await delete_record.finish()
309 |
310 | groupNum = session_id.split('_')[1]
311 | if user_id not in plugin_config.global_superuser:
312 | if groupNum not in plugin_config.quote_superuser or user_id not in plugin_config.quote_superuser[groupNum]:
313 | await bot.call_api('send_group_msg', **{
314 | 'group_id': int(groupNum),
315 | 'message': MessageSegment.at(user_id) + ' 非常抱歉, 您没有删除权限TUT'
316 | })
317 | await delete_record.finish()
318 |
319 | raw_message = str(event)
320 |
321 | errMsg = '请回复需要删除的语录, 并输入删除指令'
322 | imgs = await reply_handle(bot, errMsg, raw_message, groupNum, user_id, delete_record)
323 |
324 | # 搜索
325 | is_Delete, record_dict, inverted_index, forward_index = delete(imgs, groupNum, record_dict, inverted_index, forward_index)
326 |
327 | if is_Delete:
328 | with open(plugin_config.record_path, 'w', encoding='UTF-8') as f:
329 | json.dump(record_dict, f, indent=2, separators=(',', ': '), ensure_ascii=False)
330 | with open(plugin_config.inverted_index_path, 'w', encoding='UTF-8') as fc:
331 | json.dump(inverted_index, fc, indent=2, separators=(',', ': '), ensure_ascii=False)
332 | msg = '删除成功'
333 | else:
334 | msg = '该图不在语录库中'
335 |
336 | await delete_record.finish(group_id=int(groupNum), message=MessageSegment.at(user_id) + msg)
337 |
338 |
339 | alltag = on_command('{}alltag'.format(plugin_config.quote_startcmd), aliases={'{}标签'.format(plugin_config.quote_startcmd), '{}tag'.format(plugin_config.quote_startcmd)}, **need_at)
340 |
341 | @alltag.handle()
342 | async def alltag_handle(bot: Bot, event: Event, state: T_State):
343 |
344 | global inverted_index
345 | global record_dict
346 | global forward_index
347 |
348 | session_id = event.get_session_id()
349 | user_id = str(event.get_user_id())
350 |
351 | if 'group' not in session_id:
352 | await alltag.finish()
353 |
354 | groupNum = session_id.split('_')[1]
355 | raw_message = str(event)
356 |
357 | errMsg = '请回复需要指定语录'
358 | imgs = await reply_handle(bot, errMsg, raw_message, groupNum, user_id, alltag)
359 | tags = findAlltag(imgs, forward_index, groupNum)
360 | if tags is None:
361 | msg = '该语录不存在'
362 | else:
363 | msg = '该语录的所有Tag为: '
364 | for tag in tags:
365 | msg += tag + ' '
366 |
367 | await alltag.finish(group_id=int(groupNum), message=MessageSegment.at(user_id) + msg)
368 |
369 | addtag = on_regex(pattern="^{}addtag\ ".format(plugin_config.quote_startcmd), **need_at)
370 |
371 | @addtag.handle()
372 | async def addtag_handle(bot: Bot, event: Event, state: T_State):
373 |
374 | global inverted_index
375 | global record_dict
376 | global forward_index
377 |
378 | session_id = event.get_session_id()
379 | user_id = str(event.get_user_id())
380 | tags = str(event.get_message()).replace('{}addtag'.format(plugin_config.quote_startcmd), '').strip().split(' ')
381 |
382 | if 'group' not in session_id:
383 | await addtag.finish()
384 |
385 | groupNum = session_id.split('_')[1]
386 | raw_message = str(event)
387 |
388 | errMsg = '请回复需要指定语录'
389 | imgs = await reply_handle(bot, errMsg, raw_message, groupNum, user_id, addtag)
390 |
391 | flag, forward_index, inverted_index = addTag(tags, imgs, groupNum, forward_index, inverted_index)
392 | with open(plugin_config.inverted_index_path, 'w', encoding='UTF-8') as fc:
393 | json.dump(inverted_index, fc, indent=2, separators=(',', ': '), ensure_ascii=False)
394 |
395 | if flag is None:
396 | msg = '该语录不存在'
397 | else:
398 | msg = '已为该语录添加上{}标签'.format(tags)
399 |
400 | await addtag.finish(group_id=int(groupNum), message=MessageSegment.at(user_id) + msg)
401 |
402 |
403 | deltag = on_regex(pattern="^{}deltag\ ".format(plugin_config.quote_startcmd), **need_at)
404 |
405 | @deltag.handle()
406 | async def deltag_handle(bot: Bot, event: Event, state: T_State):
407 |
408 | global inverted_index
409 | global record_dict
410 | global forward_index
411 |
412 | session_id = event.get_session_id()
413 | user_id = str(event.get_user_id())
414 | tags = str(event.get_message()).replace('{}deltag'.format(plugin_config.quote_startcmd), '').strip().split(' ')
415 |
416 | if 'group' not in session_id:
417 | await deltag.finish()
418 |
419 | groupNum = session_id.split('_')[1]
420 | raw_message = str(event)
421 |
422 | errMsg = '请回复需要指定语录'
423 | imgs = await reply_handle(bot, errMsg, raw_message, groupNum, user_id, deltag)
424 |
425 | flag, forward_index, inverted_index = delTag(tags, imgs, groupNum, forward_index, inverted_index)
426 | with open(plugin_config.inverted_index_path, 'w', encoding='UTF-8') as fc:
427 | json.dump(inverted_index, fc, indent=2, separators=(',', ': '), ensure_ascii=False)
428 |
429 | if flag is None:
430 | msg = '该语录不存在'
431 | else:
432 | msg = '已移除该语录的{}标签'.format(tags)
433 | await deltag.finish(group_id=int(groupNum), message=MessageSegment.at(user_id) + msg)
434 |
435 |
436 | make_record = on_regex(pattern="^{}记录$".format(re.escape(plugin_config.quote_startcmd)))
437 |
438 | @make_record.handle()
439 | async def make_record_handle(bot: Bot, event: MessageEvent, state: T_State):
440 |
441 | if not check_font(font_path, author_font_path):
442 | # 字体没配置就返回
443 | logger.warning('未配置字体路径,部分功能无法使用')
444 | await make_record.finish()
445 |
446 | global inverted_index
447 | global record_dict
448 | global forward_index
449 |
450 | if event.reply:
451 | size = 640
452 | qqid = event.reply.sender.user_id
453 | raw_message = event.reply.message.extract_plain_text().strip()
454 | card = event.reply.sender.card if event.reply.sender.card not in (None, '') else event.reply.sender.nickname
455 | session_id = event.get_session_id()
456 | else:
457 | await make_record.finish("请回复所需的消息")
458 |
459 | if str(qqid) == str(event.get_user_id()):
460 | await make_record.finish("不能记录自己的消息")
461 |
462 | if raw_message:
463 |
464 | url = f"http://q1.qlogo.cn/g?b=qq&nk={qqid}&s={size}"
465 |
466 | async def download_url(url: str) -> bytes:
467 | async with httpx.AsyncClient() as client:
468 | for i in range(3):
469 | try:
470 | resp = await client.get(url, timeout=10)
471 | resp.raise_for_status()
472 | return resp.content
473 | except Exception as e:
474 | logger.warning(f"Error downloading {url}, retry {i}/3: {e}")
475 | await asyncio.sleep(3)
476 | raise NetworkError(f"{url} 下载失败!")
477 |
478 | data = await download_url(url)
479 | if hashlib.md5(data).hexdigest() == "acef72340ac0e914090bd35799f5594e":
480 | url = f"http://q1.qlogo.cn/g?b=qq&nk={qqid}&s=100"
481 | data = await download_url(url)
482 |
483 | if data:
484 | image_file = io.BytesIO(data)
485 | img_data = generate_quote_image(image_file, raw_message, card, font_path, author_font_path)
486 |
487 | image_name = hashlib.md5(img_data).hexdigest() + '.png'
488 |
489 | image_path = os.path.abspath(os.path.join(quote_path, os.path.basename(image_name)))
490 |
491 | with open(image_path, "wb") as file:
492 | file.write(img_data)
493 |
494 | if 'group' in session_id:
495 | tmpList = session_id.split('_')
496 | groupNum = tmpList[1]
497 |
498 | inverted_index, forward_index = offer(groupNum, image_path, card + ' ' + raw_message, inverted_index, forward_index)
499 |
500 | if groupNum not in record_dict:
501 | record_dict[groupNum] = [image_path]
502 | else:
503 | if image_path not in record_dict[groupNum]:
504 | record_dict[groupNum].append(image_path)
505 |
506 | with open(plugin_config.record_path, 'w', encoding='UTF-8') as f:
507 | json.dump(record_dict, f, indent=2, separators=(',', ': '), ensure_ascii=False)
508 |
509 | with open(plugin_config.inverted_index_path, 'w', encoding='UTF-8') as fc:
510 | json.dump(inverted_index, fc, indent=2, separators=(',', ': '), ensure_ascii=False)
511 |
512 | msg = MessageSegment.image(img_data)
513 | response = await bot.call_api('send_group_msg', **{
514 | 'group_id': int(groupNum),
515 | 'message': msg
516 | })
517 | else:
518 | await make_record.send('空内容')
519 | await make_record.finish()
520 |
521 | render_quote = on_regex(pattern="^{}生成$".format(re.escape(plugin_config.quote_startcmd)))
522 |
523 | @render_quote.handle()
524 | async def render_quote_handle(bot: Bot, event: MessageEvent, state: T_State):
525 |
526 | if not check_font(font_path, author_font_path):
527 | # 字体没配置就返回
528 | logger.warning('未配置字体路径,部分功能无法使用')
529 | await make_record.finish()
530 |
531 | global inverted_index
532 | global record_dict
533 | global forward_index
534 |
535 | if event.reply:
536 | size = 640
537 | qqid = event.reply.sender.user_id
538 | raw_message = event.reply.message.extract_plain_text().strip()
539 | card = event.reply.sender.card if event.reply.sender.card not in (None, '') else event.reply.sender.nickname
540 | session_id = event.get_session_id()
541 | else:
542 | await make_record.finish("请回复所需的消息")
543 |
544 | if raw_message:
545 |
546 | url = f"http://q1.qlogo.cn/g?b=qq&nk={qqid}&s={size}"
547 |
548 | async def download_url(url: str) -> bytes:
549 | async with httpx.AsyncClient() as client:
550 | for i in range(3):
551 | try:
552 | resp = await client.get(url, timeout=10)
553 | resp.raise_for_status()
554 | return resp.content
555 | except Exception as e:
556 | logger.warning(f"Error downloading {url}, retry {i}/3: {e}")
557 | await asyncio.sleep(3)
558 | raise NetworkError(f"{url} 下载失败!")
559 |
560 | data = await download_url(url)
561 | if hashlib.md5(data).hexdigest() == "acef72340ac0e914090bd35799f5594e":
562 | url = f"http://q1.qlogo.cn/g?b=qq&nk={qqid}&s=100"
563 | data = await download_url(url)
564 |
565 | if data:
566 | image_file = io.BytesIO(data)
567 | img_data = generate_quote_image(image_file, raw_message, card, font_path, author_font_path)
568 |
569 | msg = MessageSegment.image(img_data)
570 | response = await bot.call_api('send_group_msg', **{
571 | 'group_id': int(session_id.split('_')[1]),
572 | 'message': msg
573 | })
574 | else:
575 | await render_quote.send('空内容')
576 |
577 | await render_quote.finish()
578 |
579 |
580 |
581 |
582 | script_batch = on_regex(pattern="^{}batch_upload".format(plugin_config.quote_startcmd), **need_at)
583 |
584 | @script_batch.handle()
585 | async def script_batch_handle(bot: Bot, event: Event, state: T_State):
586 |
587 | global inverted_index
588 | global record_dict
589 | global forward_index
590 |
591 | session_id = event.get_session_id()
592 | user_id = str(event.get_user_id())
593 |
594 | # 必须是超级管理员群聊
595 | if user_id not in plugin_config.global_superuser:
596 | await script_batch.finish()
597 | if 'group' not in session_id:
598 | await script_batch.finish('该功能暂不支持私聊')
599 |
600 | groupNum = session_id.split('_')[1]
601 |
602 | rqqid = r"qqgroup=(.*)\s"
603 | ryour_path = r"your_path=(.*)\s"
604 | rgocq_path = r"gocq_path=(.*)\s"
605 | rtags = r"tags=(.*)"
606 |
607 | raw_msg = str(event.get_message())
608 | raw_msg = raw_msg.replace('\r', '')
609 | group_id = re.findall(rqqid, raw_msg)
610 | your_path = re.findall(ryour_path, raw_msg)
611 | gocq_path = re.findall(rgocq_path, raw_msg)
612 | tags = re.findall(rtags, raw_msg)
613 | # print(group_id, your_path, gocq_path, tags)
614 | instruction = '''指令如下:
615 | batch_upload
616 | qqgroup=123456
617 | your_path=/home/xxx/images
618 | gocq_path=/home/xxx/gocq/data/cache
619 | tags=aaa bbb ccc'''
620 | if len(group_id) == 0 or len(your_path) == 0 or len(gocq_path) == 0:
621 | await script_batch.finish(instruction)
622 | # 获取图片
623 | image_files = copy_images_files(your_path[0], gocq_path[0])
624 |
625 |
626 | total_len = len(image_files)
627 | idx = 0
628 |
629 | for (imgid, img) in image_files:
630 | save_file = '../cache/' + img
631 | idx += 1
632 | msg_id = await bot.send_msg(group_id=int(groupNum), message='[CQ:image,file={}]'.format(save_file))
633 | time.sleep(2)
634 | if group_id[0] in forward_index and save_file in forward_index[group_id[0]]:
635 | await bot.send_msg(group_id=int(groupNum), message='上述图片已存在')
636 | continue
637 | try:
638 | # 将image文件转换为PIL Image对象
639 | image = Image.open(io.BytesIO(imgid), 'utf-8')
640 | # 将PIL Image对象保存到临时路径
641 | temp_image_path = 'temp_image.jpg'
642 | image.save(temp_image_path)
643 | ocr = PaddleOCR(use_angle_cls=True, lang='ch')
644 | # 使用PaddleOCR进行OCR识别
645 | ocr_result = ocr.ocr(temp_image_path, cls=True)
646 | # 处理OCR识别结果
647 | ocr_content = ''
648 | for line in ocr_result:
649 | for word in line:
650 | ocr_content += word[1][0] + ' '
651 | ocr_content = handle_ocr_text(ocr_content)
652 |
653 | except exception.ActionFailed:
654 | await bot.send_msg(group_id=int(groupNum), message='该图片ocr失败')
655 | continue
656 |
657 | time.sleep(1)
658 | inverted_index, forward_index = offer(group_id[0], save_file, ocr_content, inverted_index, forward_index)
659 | if group_id[0] not in record_dict:
660 | record_dict[group_id[0]] = [save_file]
661 | else:
662 | if save_file not in record_dict[group_id[0]]:
663 | record_dict[group_id[0]].append(save_file)
664 |
665 | if len(tags) != 0:
666 | tags = tags[0].strip().split(' ')
667 | flag, forward_index, inverted_index = addTag(tags, imgid, group_id[0], forward_index, inverted_index)
668 |
669 | # 每5张语录持久化一次
670 | if idx % 5 == 0:
671 | with open(plugin_config.record_path, 'w', encoding='UTF-8') as f:
672 | json.dump(record_dict, f, indent=2, separators=(',', ': '), ensure_ascii=False)
673 |
674 | with open(plugin_config.inverted_index_path, 'w', encoding='UTF-8') as fc:
675 | json.dump(inverted_index, fc, indent=2, separators=(',', ': '), ensure_ascii=False)
676 |
677 | await bot.send_msg(group_id=int(groupNum), message='当前进度{}/{}'.format(idx, total_len))
678 |
679 | with open(plugin_config.record_path, 'w', encoding='UTF-8') as f:
680 | json.dump(record_dict, f, indent=2, separators=(',', ': '), ensure_ascii=False)
681 |
682 | with open(plugin_config.inverted_index_path, 'w', encoding='UTF-8') as fc:
683 | json.dump(inverted_index, fc, indent=2, separators=(',', ': '), ensure_ascii=False)
684 |
685 | await bot.send_msg(group_id=int(groupNum), message='批量导入完成')
686 | await script_batch.finish()
687 |
688 | copy_batch = on_regex(pattern="^{}batch_copy".format(plugin_config.quote_startcmd), **need_at)
689 |
690 | @copy_batch.handle()
691 | async def copy_batch_handle(bot: Bot, event: Event, state: T_State):
692 |
693 | session_id = event.get_session_id()
694 | user_id = str(event.get_user_id())
695 |
696 | # 必须是超级管理员群聊
697 | if user_id not in plugin_config.global_superuser:
698 | await copy_batch.finish()
699 |
700 |
701 | ryour_path = r"your_path=(.*)\s"
702 | rgocq_path = r"gocq_path=(.*)\s"
703 |
704 | raw_msg = str(event.get_message())
705 | raw_msg = raw_msg.replace('\r', '')
706 | your_path = re.findall(ryour_path, raw_msg)
707 | gocq_path = re.findall(rgocq_path, raw_msg)
708 | # print(your_path, gocq_path)
709 | instruction = '''指令如下:
710 | batch_copy
711 | your_path=/home/xxx/images
712 | gocq_path=/home/xxx/gocq/data/cache'''
713 | if len(your_path) == 0 or len(gocq_path) == 0:
714 | await copy_batch.finish(instruction)
715 |
716 | global record_dict
717 |
718 | try:
719 | for value in record_dict.values():
720 | for img in value:
721 | num = len(img) - 8
722 | name = img[-num:]
723 | shutil.copyfile(gocq_path[0] + name, your_path[0] + name)
724 | except FileNotFoundError:
725 | await copy_batch.finish("路径不正确")
726 | await copy_batch.finish("备份完成")
727 |
--------------------------------------------------------------------------------
/nonebot_plugin_quote/config.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel, Extra
2 | from typing import List, Dict
3 |
4 |
5 | class Config(BaseModel, extra=Extra.ignore):
6 | record_path: str = 'record.json'
7 | inverted_index_path: str = 'inverted_index.json'
8 | quote_superuser: Dict[str, List[str]] = {}
9 | global_superuser: List[str] = []
10 | quote_needat: bool = True
11 | quote_startcmd: str = ''
12 | quote_path: str = 'quote'
13 | font_path: str = 'font1'
14 | author_font_path: str = 'font2'
15 |
16 | def check_font(font_path, author_font_path):
17 | # 判断字体是否配置
18 | return not (font_path == 'font1' or author_font_path == 'font2')
--------------------------------------------------------------------------------
/nonebot_plugin_quote/make_image.py:
--------------------------------------------------------------------------------
1 | from PIL import Image, ImageDraw, ImageFont, ImageEnhance
2 | import textwrap
3 | from .pilmoji import Pilmoji
4 | from .pilmoji.source import *
5 | import httpx
6 | import io
7 |
8 | # 裁剪图片为正方形并调整大小
9 | def make_square(image, size):
10 | width, height = image.size
11 | new_size = min(width, height)
12 | left = (width - new_size) // 2
13 | top = (height - new_size) // 2
14 | right = (width + new_size) // 2
15 | bottom = (height + new_size) // 2
16 | cropped_image = image.crop((left, top, right, bottom))
17 | return cropped_image.resize((size, size), Image.LANCZOS)
18 |
19 | # 创建渐变图像
20 | def create_gradient(size):
21 | gradient = Image.new("RGBA", size)
22 | draw = ImageDraw.Draw(gradient)
23 | for x in range(size[0]):
24 | # 使用非线性透明度变化公式
25 | alpha = int(255 * (1 - (1 - x / size[0]))**2) # 更快的渐变
26 | draw.line((x, 0, x, size[1]), fill=(0, 0, 0, alpha))
27 | return gradient
28 |
29 | def generate_quote_image(avatar_bytes, text, author, font_path, author_font_path):
30 |
31 | def transbox(bbox):
32 | return bbox[2] - bbox[0], bbox[3] - bbox[1]
33 |
34 | text = " 「" + text + "」"
35 | # 固定高度为400像素,长宽比3:1
36 | fixed_height = 400
37 | canvas_width = fixed_height * 3
38 |
39 | # 裁剪头像为正方形并调整大小
40 | avatar_size = fixed_height
41 | avatar = Image.open(avatar_bytes)
42 | avatar = make_square(avatar, avatar_size)
43 |
44 | # 创建画布
45 | canvas = Image.new("RGBA", (canvas_width, fixed_height), (0, 0, 0, 0))
46 | canvas.paste(avatar, (0, 0))
47 |
48 | # 创建渐变
49 | gradient = create_gradient((avatar_size, fixed_height))
50 | canvas.paste(gradient, (0, 0), gradient)
51 |
52 | # 设置文字区域
53 | text_area_width = canvas_width - avatar_size
54 | text_area_height = fixed_height
55 | text_area = Image.new("RGBA", (text_area_width, text_area_height), (0, 0, 0, 255))
56 |
57 | # 设置字体
58 | font_size = 80
59 | font = ImageFont.truetype(font_path, font_size)
60 |
61 | # 动态调整字体大小和自动换行
62 | max_text_width = text_area_width - 40 # 留出边距
63 | max_text_height = text_area_height # 留出边距
64 | line_spacing = 10 # 添加额外的行间距
65 |
66 | wrapped_text = []
67 |
68 | if text:
69 | # 使用 textwrap 自动换行
70 | wrapped_lines = textwrap.wrap(text, width=25, drop_whitespace=False) # 调整宽度以适应
71 | lines = []
72 | current_line = []
73 | for word in wrapped_lines:
74 | current_line.append(word)
75 | if transbox(font.getbbox(''.join(current_line)))[0] >= max_text_width:
76 | lines.append(''.join(current_line[:-1]))
77 | current_line = [current_line[-1]]
78 | if current_line:
79 | lines.append(''.join(current_line))
80 | wrapped_text = lines
81 |
82 | # 调整字体大小直到文字宽度合适
83 | while True:
84 | current_width = max(transbox(font.getbbox(line))[0] for line in wrapped_text)
85 | line_height = transbox(font.getbbox("A"))[1]
86 | current_height = len(wrapped_text) * line_height + ((len(lines) - 1) * line_spacing)
87 |
88 | if current_width <= max_text_width * 0.9 and current_height <= max_text_height:
89 | break
90 |
91 | font_size -= 1
92 | font = ImageFont.truetype(font_path, font_size)
93 | wrapped_text = textwrap.wrap(text, width=25, drop_whitespace=False)
94 | # 重新分词
95 | lines = []
96 | current_line = []
97 | for word in wrapped_text:
98 | current_line.append(word)
99 | if transbox(font.getbbox(''.join(current_line)))[0] >= max_text_width:
100 | lines.append(''.join(current_line[:-1]))
101 | current_line = [current_line[-1]]
102 | if current_line:
103 | lines.append(''.join(current_line))
104 | wrapped_text = lines
105 |
106 | if font_size <= 1:
107 | break
108 |
109 | quote_content = "\n".join(wrapped_text)
110 |
111 | y = 0
112 | lines = quote_content.split("\n")
113 | line_height = transbox(font.getbbox("A"))[1]
114 |
115 | if len(lines) == 1:
116 | lines[0] = lines[0][2:]
117 |
118 | # 计算文字总高度
119 | total_content_height = len(lines) * line_height + ((len(lines) - 1) * line_spacing)
120 |
121 | # 计算居中垂直偏移量
122 | vertical_offset = (text_area_height - total_content_height) // 2 - 30
123 |
124 | # 计算文字左侧居中偏移量
125 | total_text_width = max(transbox(font.getbbox(line))[0] for line in lines)
126 | left_offset = (text_area_width - total_text_width) // 2 - 20
127 |
128 | # 绘制文本
129 | for line in lines:
130 | text_width = transbox(font.getbbox(line))[0]
131 | x = left_offset + 20 # 保留20像素左内边距
132 | with Pilmoji(text_area, source=GoogleEmojiSource) as pilmoji:
133 | pilmoji.text((x, vertical_offset + y), line, font=font, fill=(255, 255, 255, 255))
134 | y += line_height + line_spacing
135 |
136 | # 绘制作者名字
137 | author_font = ImageFont.truetype(author_font_path, 40)
138 | author_text = "— " + author
139 | author_width = transbox(author_font.getbbox(author_text))[0]
140 | author_x = text_area_width - author_width - 40
141 | author_y = text_area_height - transbox(author_font.getbbox("A"))[1] - 40
142 | with Pilmoji(text_area, source=GoogleEmojiSource) as pilmoji:
143 | pilmoji.text((author_x, author_y), author_text, font=author_font, fill=(255, 255, 255, 255))
144 |
145 | # 将文字区域粘贴到画布
146 | canvas.paste(text_area, (avatar_size, 0))
147 |
148 | # 将画布保存为字节流
149 | img_byte_arr = io.BytesIO()
150 | canvas.save(img_byte_arr, format='PNG')
151 | img_byte_arr = img_byte_arr.getvalue()
152 |
153 | return img_byte_arr
154 |
--------------------------------------------------------------------------------
/nonebot_plugin_quote/pilmoji/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2021-present jay3332
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/nonebot_plugin_quote/pilmoji/README.md:
--------------------------------------------------------------------------------
1 | # Pilmoji
2 | Pilmoji is an emoji renderer for [Pillow](https://github.com/python-pillow/Pillow/),
3 | Python's imaging library.
4 |
5 | Pilmoji comes equipped with support for both unicode emojis and Discord emojis.
6 |
7 | ## Features
8 | - Discord emoji support
9 | - Multi-line rendering support
10 | - Emoji position and/or size adjusting
11 | - Many built-in emoji sources
12 | - Optional caching
13 |
14 | ## Installation and Requirements
15 | You must have Python 3.8 or higher in order to install Pilmoji.
16 |
17 | Installation can be done with `pip`:
18 | ```shell
19 | $ pip install -U pilmoji
20 | ```
21 |
22 | Optionally, you can add the `[requests]` option to install requests
23 | alongside Pilmoji:
24 | ```shell
25 | $ pip install -U pilmoji[requests]
26 | ```
27 |
28 | The option is not required, instead if `requests` is not installed,
29 | Pilmoji will fallback to use the builtin `urllib`.
30 |
31 | You may also install from Github.
32 |
33 | ## Usage
34 | ```py
35 | from pilmoji import Pilmoji
36 | from PIL import Image, ImageFont
37 |
38 |
39 | my_string = '''
40 | Hello, world! 👋 Here are some emojis: 🎨 🌊 😎
41 | I also support Discord emoji: <:rooThink:596576798351949847>
42 | '''
43 |
44 | with Image.new('RGB', (550, 80), (255, 255, 255)) as image:
45 | font = ImageFont.truetype('arial.ttf', 24)
46 |
47 | with Pilmoji(image) as pilmoji:
48 | pilmoji.text((10, 10), my_string.strip(), (0, 0, 0), font)
49 |
50 | image.show()
51 | ```
52 |
53 | #### Result
54 | 
55 |
56 | ## Switching emoji sources
57 | As seen from the example, Pilmoji defaults to the `Twemoji` emoji source.
58 |
59 | If you prefer emojis from a different source, for example Microsoft, simply
60 | set the `source` kwarg in the constructor to a source found in the
61 | `pilmoji.source` module:
62 |
63 | ```py
64 | from pilmoji.source import MicrosoftEmojiSource
65 |
66 | with Pilmoji(image, source=MicrosoftEmojiSource) as pilmoji:
67 | ...
68 | ```
69 |
70 | 
71 |
72 | It is also possible to create your own emoji sources via subclass.
73 |
74 | ## Fine adjustments
75 | If an emoji looks too small or too big, or out of place, you can make fine adjustments
76 | with the `emoji_scale_factor` and `emoji_position_offset` kwargs:
77 |
78 | ```py
79 | pilmoji.text((10, 10), my_string.strip(), (0, 0, 0), font,
80 | emoji_scale_factor=1.15, emoji_position_offset=(0, -2))
81 | ```
82 |
83 | ## Contributing
84 | Contributions are welcome. Make sure to follow [PEP-8](https://www.python.org/dev/peps/pep-0008/)
85 | styling guidelines.
86 |
--------------------------------------------------------------------------------
/nonebot_plugin_quote/pilmoji/__init__.py:
--------------------------------------------------------------------------------
1 | from . import helpers, source
2 | from .core import Pilmoji
3 | from .helpers import *
4 |
5 | __version__ = '2.0.5'
6 | __author__ = 'jay3332'
7 |
--------------------------------------------------------------------------------
/nonebot_plugin_quote/pilmoji/core.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import math
4 |
5 | import PIL
6 | from PIL import Image, ImageDraw, ImageFont
7 |
8 | from typing import Dict, Optional, SupportsInt, TYPE_CHECKING, Tuple, Type, TypeVar, Union
9 |
10 | from .helpers import NodeType, getsize, to_nodes
11 | from .source import BaseSource, HTTPBasedSource, Twemoji, _has_requests
12 |
13 | if TYPE_CHECKING:
14 | from io import BytesIO
15 |
16 | FontT = Union[ImageFont.ImageFont, ImageFont.FreeTypeFont, ImageFont.TransposedFont]
17 | ColorT = Union[int, Tuple[int, int, int], Tuple[int, int, int, int], str]
18 |
19 |
20 | P = TypeVar('P', bound='Pilmoji')
21 |
22 | __all__ = (
23 | 'Pilmoji',
24 | )
25 |
26 |
27 | class Pilmoji:
28 | """The main emoji rendering interface.
29 |
30 | .. note::
31 | This should be used in a context manager.
32 |
33 | Parameters
34 | ----------
35 | image: :class:`PIL.Image.Image`
36 | The Pillow image to render on.
37 | source: Union[:class:`~.BaseSource`, Type[:class:`~.BaseSource`]]
38 | The emoji image source to use.
39 | This defaults to :class:`~.TwitterEmojiSource`.
40 | cache: bool
41 | Whether or not to cache emojis given from source.
42 | Enabling this is recommended and by default.
43 | draw: :class:`PIL.ImageDraw.ImageDraw`
44 | The drawing instance to use. If left unfilled,
45 | a new drawing instance will be created.
46 | render_discord_emoji: bool
47 | Whether or not to render Discord emoji. Defaults to `True`
48 | emoji_scale_factor: float
49 | The default rescaling factor for emojis. Defaults to `1`
50 | emoji_position_offset: Tuple[int, int]
51 | A 2-tuple representing the x and y offset for emojis when rendering,
52 | respectively. Defaults to `(0, 0)`
53 | disk_cache: bool
54 | Whether or not to permanently cache cdn-fetched emojis to disk,
55 | defaults to `False` but can greatly improve speed in certain cases.
56 | """
57 |
58 | def __init__(
59 | self,
60 | image: Image.Image,
61 | *,
62 | source: Union[BaseSource, Type[BaseSource]] = Twemoji,
63 | cache: bool = True,
64 | draw: Optional[ImageDraw.ImageDraw] = None,
65 | render_discord_emoji: bool = True,
66 | emoji_scale_factor: float = 1.0,
67 | emoji_position_offset: Tuple[int, int] = (0, 0),
68 | disk_cache: bool = False,
69 | ) -> None:
70 | self.image: Image.Image = image
71 | self.draw: ImageDraw.ImageDraw = draw
72 |
73 | if isinstance(source, type):
74 | if not issubclass(source, BaseSource):
75 | raise TypeError(f'source must inherit from BaseSource, not {source}.')
76 |
77 | source = source(disk_cache=disk_cache)
78 |
79 | elif not isinstance(source, BaseSource):
80 | raise TypeError(f'source must inherit from BaseSource, not {source.__class__}.')
81 |
82 | self.source: BaseSource = source
83 |
84 | self._cache: bool = cache
85 | self._closed: bool = False
86 | self._new_draw: bool = False
87 |
88 | self._render_discord_emoji: bool = render_discord_emoji
89 | self._default_emoji_scale_factor: float = emoji_scale_factor
90 | self._default_emoji_position_offset: Tuple[int, int] = emoji_position_offset
91 |
92 | self._emoji_cache: Dict[str, BytesIO] = {}
93 | self._discord_emoji_cache: Dict[int, BytesIO] = {}
94 |
95 | self._create_draw()
96 |
97 | def open(self) -> None:
98 | """Re-opens this renderer if it has been closed.
99 | This should rarely be called.
100 |
101 | Raises
102 | ------
103 | ValueError
104 | The renderer is already open.
105 | """
106 | if not self._closed:
107 | raise ValueError('Renderer is already open.')
108 |
109 | if _has_requests and isinstance(self.source, HTTPBasedSource):
110 | from requests import Session
111 | self.source._requests_session = Session()
112 |
113 | self._create_draw()
114 | self._closed = False
115 |
116 | def close(self) -> None:
117 | """Safely closes this renderer.
118 |
119 | .. note::
120 | If you are using a context manager, this should not be called.
121 |
122 | Raises
123 | ------
124 | ValueError
125 | The renderer has already been closed.
126 | """
127 | if self._closed:
128 | raise ValueError('Renderer has already been closed.')
129 |
130 | if self._new_draw:
131 | del self.draw
132 | self.draw = None
133 |
134 | if _has_requests and isinstance(self.source, HTTPBasedSource):
135 | self.source._requests_session.close()
136 |
137 | if self._cache:
138 | for stream in self._emoji_cache.values():
139 | stream.close()
140 |
141 | for stream in self._discord_emoji_cache.values():
142 | stream.close()
143 |
144 | self._emoji_cache = {}
145 | self._discord_emoji_cache = {}
146 |
147 | self._closed = True
148 |
149 | def _create_draw(self) -> None:
150 | if self.draw is None:
151 | self._new_draw = True
152 | self.draw = ImageDraw.Draw(self.image)
153 |
154 | def _get_emoji(self, emoji: str, /) -> Optional[BytesIO]:
155 | if self._cache and emoji in self._emoji_cache:
156 | entry = self._emoji_cache[emoji]
157 | entry.seek(0)
158 | return entry
159 |
160 | if stream := self.source.get_emoji(emoji):
161 | if self._cache:
162 | self._emoji_cache[emoji] = stream
163 |
164 | stream.seek(0)
165 | return stream
166 |
167 | def _get_discord_emoji(self, id: SupportsInt, /) -> Optional[BytesIO]:
168 | id = int(id)
169 |
170 | if self._cache and id in self._discord_emoji_cache:
171 | entry = self._discord_emoji_cache[id]
172 | entry.seek(0)
173 | return entry
174 |
175 | if stream := self.source.get_discord_emoji(id):
176 | if self._cache:
177 | self._discord_emoji_cache[id] = stream
178 |
179 | stream.seek(0)
180 | return stream
181 |
182 | def getsize(
183 | self,
184 | text: str,
185 | font: FontT = None,
186 | *,
187 | spacing: int = 4,
188 | emoji_scale_factor: float = None
189 | ) -> Tuple[int, int]:
190 | """Return the width and height of the text when rendered.
191 | This method supports multiline text.
192 |
193 | Parameters
194 | ----------
195 | text: str
196 | The text to use.
197 | font
198 | The font of the text.
199 | spacing: int
200 | The spacing between lines, in pixels.
201 | Defaults to `4`.
202 | emoji_scalee_factor: float
203 | The rescaling factor for emojis.
204 | Defaults to the factor given in the class constructor, or `1`.
205 | """
206 | if emoji_scale_factor is None:
207 | emoji_scale_factor = self._default_emoji_scale_factor
208 |
209 | return getsize(text, font, spacing=spacing, emoji_scale_factor=emoji_scale_factor)
210 |
211 | def text(
212 | self,
213 | xy: Tuple[int, int],
214 | text: str,
215 | fill: ColorT = None,
216 | font: FontT = None,
217 | anchor: str = None,
218 | spacing: int = 4,
219 | node_spacing: int = 0,
220 | align: str = "left",
221 | direction: str = None,
222 | features: str = None,
223 | language: str = None,
224 | stroke_width: int = 0,
225 | stroke_fill: ColorT = None,
226 | embedded_color: bool = False,
227 | *args,
228 | emoji_scale_factor: float = None,
229 | emoji_position_offset: Tuple[int, int] = None,
230 | **kwargs
231 | ) -> None:
232 | """Draws the string at the given position, with emoji rendering support.
233 | This method supports multiline text.
234 |
235 | .. note::
236 | Some parameters have not been implemented yet.
237 |
238 | .. note::
239 | The signature of this function is a superset of the signature of Pillow's `ImageDraw.text`.
240 |
241 | .. note::
242 | Not all parameters are listed here.
243 |
244 | Parameters
245 | ----------
246 | xy: Tuple[int, int]
247 | The position to render the text at.
248 | text: str
249 | The text to render.
250 | fill
251 | The fill color of the text.
252 | font
253 | The font to render the text with.
254 | spacing: int
255 | How many pixels there should be between lines. Defaults to `4`
256 | node_spacing: int
257 | How many pixels there should be between nodes (text/unicode_emojis/custom_emojis). Defaults to `0`
258 | emoji_scale_factor: float
259 | The rescaling factor for emojis. This can be used for fine adjustments.
260 | Defaults to the factor given in the class constructor, or `1`.
261 | emoji_position_offset: Tuple[int, int]
262 | The emoji position offset for emojis. This can be used for fine adjustments.
263 | Defaults to the offset given in the class constructor, or `(0, 0)`.
264 | """
265 |
266 | if emoji_scale_factor is None:
267 | emoji_scale_factor = self._default_emoji_scale_factor
268 |
269 | if emoji_position_offset is None:
270 | emoji_position_offset = self._default_emoji_position_offset
271 |
272 | if font is None:
273 | font = ImageFont.load_default()
274 |
275 | # first we need to test the anchor
276 | # because we want to make the exact same positions transformations than the "ImageDraw"."text" function in PIL
277 | # https://github.com/python-pillow/Pillow/blob/66c244af3233b1cc6cc2c424e9714420aca109ad/src/PIL/ImageDraw.py#L449
278 |
279 | # also we are note using the "ImageDraw"."multiline_text" since when we are cuting the text in nodes
280 | # a lot of code could be simplify this way
281 | # https://github.com/python-pillow/Pillow/blob/66c244af3233b1cc6cc2c424e9714420aca109ad/src/PIL/ImageDraw.py#L567
282 |
283 | if anchor is None:
284 | anchor = "la"
285 | elif len(anchor) != 2:
286 | msg = "anchor must be a 2 character string"
287 | raise ValueError(msg)
288 | elif anchor[1] in "tb" and "\n" in text:
289 | msg = "anchor not supported for multiline text"
290 | raise ValueError(msg)
291 |
292 | # need to be checked here because we are not using the real "ImageDraw"."multiline_text"
293 | if direction == "ttb" and "\n" in text:
294 | msg = "ttb direction is unsupported for multiline text"
295 | raise ValueError(msg)
296 |
297 | def getink(fill):
298 | ink, fill = self.draw._getink(fill)
299 | if ink is None:
300 | return fill
301 | return ink
302 |
303 | x, y = xy
304 | original_x = x
305 | nodes = to_nodes(text)
306 | # get the distance between lines ( will be add to y between each line )
307 | line_spacing = self.draw._multiline_spacing(font, spacing, stroke_width)
308 |
309 | # I change a part of the logic of text writing because it couldn't work "the same as PIL" if I didn't
310 | nodes_line_to_print = []
311 | widths = []
312 | max_width = 0
313 | streams = {}
314 | mode = self.draw.fontmode
315 | if stroke_width == 0 and embedded_color:
316 | mode = "RGBA"
317 | ink = getink(fill)
318 | # we get the size taken by a " " to be drawn with the given options
319 | space_text_lenght = self.draw.textlength(" ", font, direction=direction, features=features, language=language, embedded_color=embedded_color)
320 |
321 | for node_id, line in enumerate(nodes):
322 | text_line = ""
323 | streams[node_id] = {}
324 | for line_id, node in enumerate(line):
325 | content = node.content
326 | stream = None
327 | if node.type is NodeType.emoji:
328 | stream = self._get_emoji(content)
329 |
330 | elif self._render_discord_emoji and node.type is NodeType.discord_emoji:
331 | stream = self._get_discord_emoji(content)
332 |
333 | if stream:
334 | streams[node_id][line_id] = stream
335 |
336 | if node.type is NodeType.text or not stream:
337 | # each text in the same line are concatenate
338 | text_line += node.content
339 | continue
340 |
341 | with Image.open(stream).convert('RGBA') as asset:
342 | width = round(emoji_scale_factor * font.size)
343 | ox, oy = emoji_position_offset
344 | size = round(width + ox + (node_spacing * 2))
345 | # for every emoji we calculate the space needed to display it in the current text
346 | space_to_had = round(size / space_text_lenght)
347 | # we had the equivalent space as " " caracter in the line text
348 | text_line += "".join(" " for x in range(space_to_had))
349 |
350 | #saving each line with the place to display emoji at the right place
351 | nodes_line_to_print.append(text_line)
352 | line_width = self.draw.textlength(
353 | text_line, font, direction=direction, features=features, language=language
354 | )
355 | widths.append(line_width)
356 | max_width = max(max_width, line_width)
357 |
358 | # taking into acount the anchor to place the text in the right place
359 | if anchor[1] == "m":
360 | y -= (len(nodes) - 1) * line_spacing / 2.0
361 | elif anchor[1] == "d":
362 | y -= (len(nodes) - 1) * line_spacing
363 |
364 | for node_id, line in enumerate(nodes):
365 | # restore the original x wanted for each line
366 | x = original_x
367 | # some transformations should not be applied to y
368 | line_y = y
369 | width_difference = max_width - widths[node_id]
370 |
371 | # first align left by anchor
372 | if anchor[0] == "m":
373 | x -= width_difference / 2.0
374 | elif anchor[0] == "r":
375 | x -= width_difference
376 |
377 | # then align by align parameter
378 | if align == "left":
379 | pass
380 | elif align == "center":
381 | x += width_difference / 2.0
382 | elif align == "right":
383 | x += width_difference
384 | else:
385 | msg = 'align must be "left", "center" or "right"'
386 | raise ValueError(msg)
387 |
388 | # if this line hase text to display then we draw it all at once ( one time only per line )
389 | if len(nodes_line_to_print[node_id]) > 0:
390 | self.draw.text(
391 | (x, line_y),
392 | nodes_line_to_print[node_id],
393 | fill=fill,
394 | font=font,
395 | anchor=anchor,
396 | spacing=spacing,
397 | align=align,
398 | direction=direction,
399 | features=features,
400 | language=language,
401 | stroke_width=stroke_width,
402 | stroke_fill=stroke_fill,
403 | embedded_color=embedded_color,
404 | *args,
405 | **kwargs
406 | )
407 |
408 | coord = []
409 | start = []
410 | for i in range(2):
411 | coord.append(int((x, y)[i]))
412 | start.append(math.modf((x, y)[i])[0])
413 |
414 | # respecting the way parameters are used in PIL to find the good x and y
415 | if ink is not None:
416 | stroke_ink = None
417 | if stroke_width:
418 | stroke_ink = getink(stroke_fill) if stroke_fill is not None else ink
419 |
420 | if stroke_ink is not None:
421 | ink = stroke_ink
422 | try:
423 | _, offset = font.getmask2(
424 | nodes_line_to_print[node_id],
425 | mode,
426 | direction=direction,
427 | features=features,
428 | language=language,
429 | anchor=anchor,
430 | ink=ink,
431 | start=start,
432 | *args,
433 | **kwargs,
434 | )
435 | coord = coord[0] + offset[0], coord[1] + offset[1]
436 | except AttributeError:
437 | pass
438 | x, line_y = coord
439 |
440 | for line_id, node in enumerate(line):
441 | content = node.content
442 |
443 | # if node is text then we decale our x
444 | # but since the text line as already be drawn we do not need to draw text here anymore
445 | if node.type is NodeType.text or line_id not in streams[node_id]:
446 | if tuple(int(part) for part in PIL.__version__.split(".")) >= (9, 2, 0):
447 | width = int(font.getlength(content, direction=direction, features=features, language=language))
448 | else:
449 | width, _ = font.getsize(content)
450 | x += node_spacing + width
451 | continue
452 |
453 | if line_id in streams[node_id]:
454 | with Image.open(streams[node_id][line_id]).convert('RGBA') as asset:
455 | width = round(emoji_scale_factor * font.size)
456 | size = width, round(math.ceil(asset.height / asset.width * width))
457 | asset = asset.resize(size, Image.Resampling.LANCZOS)
458 | ox, oy = emoji_position_offset
459 |
460 | self.image.paste(asset, (round(x + ox), round(line_y + oy)), asset)
461 |
462 | x += node_spacing + width
463 | y += line_spacing
464 |
465 | def __enter__(self: P) -> P:
466 | return self
467 |
468 | def __exit__(self, *_) -> None:
469 | self.close()
470 |
471 | def __repr__(self) -> str:
472 | return f''
473 |
--------------------------------------------------------------------------------
/nonebot_plugin_quote/pilmoji/helpers.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import re
4 |
5 | from enum import Enum
6 |
7 | import emoji
8 |
9 | import PIL
10 | from PIL import ImageFont
11 |
12 | from typing import Dict, Final, List, NamedTuple, TYPE_CHECKING, Tuple
13 |
14 | if TYPE_CHECKING:
15 | from .core import FontT
16 |
17 | # This is actually way faster than it seems
18 | # Create a dictionary mapping English emoji descriptions to their unicode representations
19 | # Only include emojis that have an English description and are fully qualified
20 | language_pack: Dict[str, str] = {
21 | data['en']: emj
22 | for emj, data in emoji.EMOJI_DATA.items()
23 | if 'en' in data and data['status'] <= emoji.STATUS['fully_qualified']
24 | }
25 | _UNICODE_EMOJI_REGEX = '|'.join(map(re.escape, sorted(language_pack.values(), key=len, reverse=True)))
26 | _DISCORD_EMOJI_REGEX = ''
27 |
28 | EMOJI_REGEX: Final[re.Pattern[str]] = re.compile(f'({_UNICODE_EMOJI_REGEX}|{_DISCORD_EMOJI_REGEX})')
29 |
30 | __all__ = (
31 | 'EMOJI_REGEX',
32 | 'Node',
33 | 'NodeType',
34 | 'to_nodes',
35 | 'getsize'
36 | )
37 |
38 |
39 | class NodeType(Enum):
40 | """|enum|
41 |
42 | Represents the type of a :class:`~.Node`.
43 |
44 | Attributes
45 | ----------
46 | text
47 | This node is a raw text node.
48 | emoji
49 | This node is a unicode emoji.
50 | discord_emoji
51 | This node is a Discord emoji.
52 | """
53 |
54 | text = 0
55 | emoji = 1
56 | discord_emoji = 2
57 |
58 |
59 | class Node(NamedTuple):
60 | """Represents a parsed node inside of a string.
61 |
62 | Attributes
63 | ----------
64 | type: :class:`~.NodeType`
65 | The type of this node.
66 | content: str
67 | The contents of this node.
68 | """
69 |
70 | type: NodeType
71 | content: str
72 |
73 | def __repr__(self) -> str:
74 | return f''
75 |
76 |
77 | def _parse_line(line: str, /) -> List[Node]:
78 | nodes = []
79 |
80 | for i, chunk in enumerate(EMOJI_REGEX.split(line)):
81 | if not chunk:
82 | continue
83 |
84 | if not i % 2:
85 | nodes.append(Node(NodeType.text, chunk))
86 | continue
87 |
88 | if len(chunk) > 18: # This is guaranteed to be a Discord emoji
89 | node = Node(NodeType.discord_emoji, chunk.split(':')[-1][:-1])
90 | else:
91 | node = Node(NodeType.emoji, chunk)
92 |
93 | nodes.append(node)
94 |
95 | return nodes
96 |
97 |
98 | def to_nodes(text: str, /) -> List[List[Node]]:
99 | """Parses a string of text into :class:`~.Node`s.
100 |
101 | This method will return a nested list, each element of the list
102 | being a list of :class:`~.Node`s and representing a line in the string.
103 |
104 | The string ``'Hello\nworld'`` would return something similar to
105 | ``[[Node('Hello')], [Node('world')]]``.
106 |
107 | Parameters
108 | ----------
109 | text: str
110 | The text to parse into nodes.
111 |
112 | Returns
113 | -------
114 | List[List[:class:`~.Node`]]
115 | """
116 | return [_parse_line(line) for line in text.splitlines()]
117 |
118 |
119 | def getsize(
120 | text: str,
121 | font: FontT = None,
122 | *,
123 | spacing: int = 4,
124 | emoji_scale_factor: float = 1
125 | ) -> Tuple[int, int]:
126 | """Return the width and height of the text when rendered.
127 | This method supports multiline text.
128 |
129 | Parameters
130 | ----------
131 | text: str
132 | The text to use.
133 | font
134 | The font of the text.
135 | spacing: int
136 | The spacing between lines, in pixels.
137 | Defaults to `4`.
138 | emoji_scale_factor: float
139 | The rescaling factor for emojis.
140 | Defaults to `1`.
141 | """
142 | if font is None:
143 | font = ImageFont.load_default()
144 |
145 | x, y = 0, 0
146 | nodes = to_nodes(text)
147 |
148 | for line in nodes:
149 | this_x = 0
150 | for node in line:
151 | content = node.content
152 |
153 | if node.type is not NodeType.text:
154 | width = int(emoji_scale_factor * font.size)
155 | elif tuple(int(part) for part in PIL.__version__.split(".")) >= (9, 2, 0):
156 | width = int(font.getlength(content))
157 | else:
158 | width, _ = font.getsize(content)
159 |
160 | this_x += width
161 |
162 | y += spacing + font.size
163 |
164 | if this_x > x:
165 | x = this_x
166 |
167 | return x, y - spacing
168 |
--------------------------------------------------------------------------------
/nonebot_plugin_quote/pilmoji/source.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from io import BytesIO
3 |
4 | from urllib.request import Request, urlopen
5 | from urllib.error import HTTPError
6 | from urllib.parse import quote_plus
7 |
8 | from typing import Any, ClassVar, Dict, Optional
9 |
10 | try:
11 | import requests
12 | _has_requests = True
13 | except ImportError:
14 | requests = None
15 | _has_requests = False
16 |
17 | __all__ = (
18 | 'BaseSource',
19 | 'HTTPBasedSource',
20 | 'DiscordEmojiSourceMixin',
21 | 'EmojiCDNSource',
22 | 'TwitterEmojiSource',
23 | 'AppleEmojiSource',
24 | 'GoogleEmojiSource',
25 | 'FacebookEmojiSource',
26 | 'TwemojiEmojiSource',
27 | 'Twemoji',
28 | )
29 |
30 |
31 | class BaseSource(ABC):
32 | """The base class for an emoji image source."""
33 |
34 | @abstractmethod
35 | def get_emoji(self, emoji: str, /) -> Optional[BytesIO]:
36 | """Retrieves a :class:`io.BytesIO` stream for the image of the given emoji.
37 |
38 | Parameters
39 | ----------
40 | emoji: str
41 | The emoji to retrieve.
42 |
43 | Returns
44 | -------
45 | :class:`io.BytesIO`
46 | A bytes stream of the emoji.
47 | None
48 | An image for the emoji could not be found.
49 | """
50 | raise NotImplementedError
51 |
52 | @abstractmethod
53 | def get_discord_emoji(self, id: int, /) -> Optional[BytesIO]:
54 | """Retrieves a :class:`io.BytesIO` stream for the image of the given Discord emoji.
55 |
56 | Parameters
57 | ----------
58 | id: int
59 | The snowflake ID of the Discord emoji.
60 |
61 | Returns
62 | -------
63 | :class:`io.BytesIO`
64 | A bytes stream of the emoji.
65 | None
66 | An image for the emoji could not be found.
67 | """
68 | raise NotImplementedError
69 |
70 | def __repr__(self) -> str:
71 | return f'<{self.__class__.__name__}>'
72 |
73 |
74 | class HTTPBasedSource(BaseSource):
75 | """Represents an HTTP-based source."""
76 |
77 | REQUEST_KWARGS: ClassVar[Dict[str, Any]] = {
78 | 'headers': {'User-Agent': 'Mozilla/5.0'}
79 | }
80 |
81 | def __init__(self) -> None:
82 | if _has_requests:
83 | self._requests_session = requests.Session()
84 |
85 | def request(self, url: str) -> bytes:
86 | """Makes a GET request to the given URL.
87 |
88 | If the `requests` library is installed, it will be used.
89 | If it is not installed, :meth:`urllib.request.urlopen` will be used instead.
90 |
91 | Parameters
92 | ----------
93 | url: str
94 | The URL to request from.
95 |
96 | Returns
97 | -------
98 | bytes
99 |
100 | Raises
101 | ------
102 | Union[:class:`requests.HTTPError`, :class:`urllib.error.HTTPError`]
103 | There was an error requesting from the URL.
104 | """
105 | if _has_requests:
106 | with self._requests_session.get(url, **self.REQUEST_KWARGS) as response:
107 | if response.ok:
108 | return response.content
109 | else:
110 | req = Request(url, **self.REQUEST_KWARGS)
111 | with urlopen(req) as response:
112 | return response.read()
113 |
114 | @abstractmethod
115 | def get_emoji(self, emoji: str, /) -> Optional[BytesIO]:
116 | raise NotImplementedError
117 |
118 | @abstractmethod
119 | def get_discord_emoji(self, id: int, /) -> Optional[BytesIO]:
120 | raise NotImplementedError
121 |
122 |
123 | class DiscordEmojiSourceMixin(HTTPBasedSource):
124 | """A mixin that adds Discord emoji functionality to another source."""
125 |
126 | BASE_DISCORD_EMOJI_URL: ClassVar[str] = 'https://cdn.discordapp.com/emojis/'
127 |
128 | @abstractmethod
129 | def get_emoji(self, emoji: str, /) -> Optional[BytesIO]:
130 | raise NotImplementedError
131 |
132 | def get_discord_emoji(self, id: int, /) -> Optional[BytesIO]:
133 | url = self.BASE_DISCORD_EMOJI_URL + str(id) + '.png'
134 | _to_catch = HTTPError if not _has_requests else requests.HTTPError
135 |
136 | try:
137 | return BytesIO(self.request(url))
138 | except _to_catch:
139 | pass
140 |
141 |
142 | class EmojiCDNSource(DiscordEmojiSourceMixin):
143 | """A base source that fetches emojis from https://emojicdn.elk.sh/."""
144 |
145 | BASE_EMOJI_CDN_URL: ClassVar[str] = 'https://emojicdn.elk.sh/'
146 | STYLE: ClassVar[str] = None
147 | CACHE_DIR: ClassVar[str] = 'emoji_cache'
148 |
149 | def __init__(self, disk_cache=False):
150 | super().__init__()
151 | self.disk_cache = disk_cache
152 | if self.disk_cache:
153 | self.cache_dir = Path(self.CACHE_DIR)
154 | self.cache_dir.mkdir(exist_ok=True)
155 |
156 | def get_emoji(self, emoji: str, /) -> Optional[BytesIO]:
157 | if self.STYLE is None:
158 | raise TypeError('STYLE class variable unfilled.')
159 |
160 | if self.disk_cache:
161 | cache_file = self.cache_dir / f"{emoji}_{self.STYLE}.png"
162 |
163 | if cache_file.exists():
164 | with cache_file.open('rb') as f:
165 | return BytesIO(f.read())
166 | else:
167 | url = self.BASE_EMOJI_CDN_URL + quote_plus(emoji) + '?style=' + quote_plus(self.STYLE)
168 | _to_catch = HTTPError if not _has_requests else requests.HTTPError
169 |
170 | try:
171 | data = self.request(url)
172 | with cache_file.open('wb') as f:
173 | f.write(data)
174 | return BytesIO(data)
175 | except _to_catch:
176 | pass
177 | else:
178 | url = self.BASE_EMOJI_CDN_URL + quote_plus(emoji) + '?style=' + quote_plus(self.STYLE)
179 | _to_catch = HTTPError if not _has_requests else requests.HTTPError
180 |
181 | try:
182 | return BytesIO(self.request(url))
183 | except _to_catch:
184 | pass
185 |
186 |
187 | class TwitterEmojiSource(EmojiCDNSource):
188 | """A source that uses Twitter-style emojis. These are also the ones used in Discord."""
189 | STYLE = 'twitter'
190 |
191 |
192 | class AppleEmojiSource(EmojiCDNSource):
193 | """A source that uses Apple emojis."""
194 | STYLE = 'apple'
195 |
196 |
197 | class GoogleEmojiSource(EmojiCDNSource):
198 | """A source that uses Google emojis."""
199 | STYLE = 'google'
200 |
201 |
202 | class FacebookEmojiSource(EmojiCDNSource):
203 | """A source that uses Facebook emojis."""
204 | STYLE = 'facebook'
205 |
206 |
207 | # Aliases
208 | TwemojiEmojiSource = Twemoji = TwitterEmojiSource
209 |
--------------------------------------------------------------------------------
/nonebot_plugin_quote/task.py:
--------------------------------------------------------------------------------
1 | import json
2 | import jieba
3 | import os
4 | import random
5 | import hashlib
6 | import shutil
7 |
8 |
9 | # 向语录库添加新的图片
10 | def offer(group_id, img_file, content, inverted_index, forward_index):
11 | # 分词
12 | cut_words = cut_sentence(content)
13 | # 群号是否在表中
14 | if group_id not in inverted_index:
15 | inverted_index[group_id] = {}
16 | forward_index[group_id] = {}
17 |
18 | forward_index[group_id][img_file] = set(cut_words)
19 | # 分词是否在群的hashmap里
20 | for word in cut_words:
21 | if word not in inverted_index[group_id]:
22 | inverted_index[group_id][word] = [img_file]
23 | else:
24 | inverted_index[group_id][word].append(img_file)
25 |
26 | return inverted_index, forward_index
27 |
28 |
29 | # 倒排索引表查询图片
30 | def query(sentence, group_id, inverted_index):
31 | if sentence.startswith('#'):
32 | cut_words = [sentence[1:]]
33 | else:
34 | cut_words = jieba.lcut_for_search(sentence)
35 | cut_words = list(set(cut_words))
36 | if group_id not in inverted_index:
37 | return {'status': -1}
38 | hash_map = inverted_index[group_id]
39 | count_map = {}
40 | result_pool = []
41 | for word in cut_words:
42 | if word not in hash_map:
43 | return {'status': 2}
44 | for img in hash_map[word]:
45 | if img not in count_map:
46 | count_map[img] = 1
47 | else:
48 | count_map[img] += 1
49 | if count_map[img] == len(cut_words):
50 | result_pool.append(img)
51 |
52 | if len(result_pool) == 0:
53 | return {'status': 2}
54 | idx = random.randint(0, len(result_pool)-1)
55 | return {'status': 1, 'msg': result_pool[idx]}
56 |
57 |
58 | # 删除内容
59 | def delete(img_name, group_id, record, inverted_index, forward_index):
60 | check = False
61 | try:
62 | keys = list(inverted_index[group_id].keys())
63 | for key in keys:
64 | check = _remove(inverted_index[group_id][key], img_name) or check
65 | if len(inverted_index[group_id][key]) == 0:
66 | del inverted_index[group_id][key]
67 |
68 | check = _remove(record[group_id], img_name) or check
69 | if len(record[group_id]) == 0:
70 | del record[group_id]
71 |
72 | for key in forward_index[group_id].keys():
73 | file_name = os.path.basename(key)
74 | if file_name.startswith(img_name):
75 | del forward_index[group_id][key]
76 | break
77 |
78 | return check, record, inverted_index, forward_index
79 | except KeyError:
80 | return check, record, inverted_index, forward_index
81 |
82 |
83 | def _remove(arr, ele):
84 | old_len = len(arr)
85 | for name in arr:
86 | file_name = os.path.basename(name)
87 | if file_name.startswith(ele):
88 | arr.remove(name)
89 | break
90 |
91 | return len(arr) < old_len
92 |
93 |
94 |
95 | def handle_ocr_text(texts):
96 | _len_ = len(texts)
97 | if _len_ == 0:
98 | return ''
99 | ret = texts[0]['text']
100 | for i in range(1, _len_):
101 | _last_vectors = texts[i-1]['coordinates']
102 | _cur_vectors = texts[i]['coordinates']
103 | _last_width = _last_vectors[1]['x'] - _last_vectors[0]['x']
104 | _cur_width = _cur_vectors[1]['x'] - _cur_vectors[0]['x']
105 | _last_start = _last_vectors[0]['x']
106 | _cur_start = _cur_vectors[0]['x']
107 |
108 | _last_end = _last_vectors[1]['x']
109 | _cur_end = _cur_vectors[1]['x']
110 | # 起始点判断 误差在15以内
111 | # 长度判断 上一句比下一句长 误差在5以内
112 | if abs(_cur_start - _last_start) <= 15 and _last_width + 5 > _cur_width:
113 | # 判定为长句换行了
114 | ret += texts[i]['text']
115 | # 终点判断 误差在15以内
116 | # 长度判断 上一句比下一句短 误差在5以内
117 | elif abs(_cur_end - _last_end) <= 15 and _cur_width + 5 > _last_width:
118 | # 判定为长句换行了
119 | ret += texts[i]['text']
120 | else:
121 | ret += '\n' + texts[i]['text']
122 |
123 | return ret
124 |
125 |
126 | def cut_sentence(sentence):
127 | cut_words = jieba.lcut_for_search(sentence)
128 | cut_words = list(set(cut_words))
129 | remove_set = set(['.',',','!','?',':',';','。',',','!','?',':',';','%','$','\n',' ','[',']'])
130 | new_words = [word for word in cut_words if word not in remove_set]
131 |
132 | return new_words
133 |
134 |
135 | # 倒排索引 转 正向索引
136 | def inverted2forward(inverted_index):
137 | forward_index = {}
138 | for qq_group in inverted_index.keys():
139 | forward_index[qq_group] = {}
140 | for word, imgs in inverted_index[qq_group].items():
141 | for img in imgs:
142 | forward_index[qq_group].setdefault(img, set()).add(word)
143 | return forward_index
144 |
145 |
146 | # 输出所有tag
147 | def findAlltag(img_name, forward_index, group_id):
148 | for key, value in forward_index[group_id].items():
149 | file_name = os.path.basename(key)
150 | if file_name.startswith(img_name):
151 | return value
152 |
153 |
154 | # 添加tag
155 | def addTag(tags, img_name, group_id, forward_index, inverted_index):
156 | # 是否存在
157 | path = None
158 | for key in forward_index[group_id].keys():
159 | file_name = os.path.basename(key)
160 | if file_name.startswith(img_name):
161 | path = key
162 | for tag in tags:
163 | forward_index[group_id][key].add(tag)
164 | break
165 | if path is None:
166 | return None, forward_index, inverted_index
167 | for tag in tags:
168 | inverted_index[group_id].setdefault(tag, []).append(path)
169 | return path, forward_index, inverted_index
170 |
171 |
172 | # 删除tag
173 | def delTag(tags, img_name, group_id, forward_index, inverted_index):
174 | path = None
175 | for key in forward_index[group_id].keys():
176 | file_name = os.path.basename(key)
177 | if file_name.startswith(img_name):
178 | path = key
179 | for tag in tags:
180 | forward_index[group_id][key].discard(tag)
181 | break
182 | if path is None:
183 | return None, forward_index, inverted_index
184 | keys = list(inverted_index[group_id].keys())
185 | for tag in tags:
186 | if tag in keys and path in inverted_index[group_id][tag]:
187 | inverted_index[group_id][tag].remove(path)
188 | if len(inverted_index[group_id][tag]) == 0:
189 | del inverted_index[group_id][tag]
190 | return path, forward_index, inverted_index
191 |
192 |
193 | IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif']
194 | def copy_images_files(source, destinate):
195 | image_files = []
196 | for root,_,files in os.walk(source):
197 | for filename in files:
198 | extension = os.path.splitext(filename)[1].lower()
199 | if extension in IMAGE_EXTENSIONS:
200 | image_path = os.path.join(root, filename)
201 | # 获得md5
202 | md5 = get_img_md5(image_path) + '.image'
203 | tname = md5 + extension
204 | # 复制到目录
205 | destination_path = os.path.join(destinate, tname)
206 | shutil.copy(image_path, destination_path)
207 | image_files.append((md5, tname))
208 | return image_files
209 |
210 |
211 | def get_img_md5(img_path):
212 | with open(img_path, 'rb') as f:
213 | img_data = f.read()
214 | md5 = hashlib.md5(img_data).hexdigest()
215 | return md5
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "nonebot-plugin-quote"
3 | version = "0.4.2"
4 | description = "一款适用于QQ群聊天的语录库插件"
5 | authors = ["RongRongJi <316315867@qq.com>", "Hanserprpr <2041283903@qq.com>", "Pigz2538 <2281717797@qq.com>"]
6 | license = "MIT"
7 | readme = "README.md"
8 | packages = [{include = "nonebot_plugin_quote"}]
9 |
10 | [tool.poetry.dependencies]
11 | python = "^3.8"
12 | jieba = "^0.42.1"
13 | nonebot2 = "^2.0.0rc3"
14 | nonebot-adapter-onebot = "^2.2.1"
15 | paddleocr = "^2.9.1"
16 | paddlepaddle = "^2.6.2"
17 | setuptools = "^75.3.0"
18 | httpx = "^0.27.2"
19 | pillow = ">=11.1,<11.2"
20 | emoji = "^2.14"
21 |
22 | [build-system]
23 | requires = ["poetry-core"]
24 | build-backend = "poetry.core.masonry.api"
25 |
--------------------------------------------------------------------------------
/screenshot/auto_generate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RongRongJi/nonebot_plugin_quote/6eccc9909d5401badccaee52599ac6a86fe0ffda/screenshot/auto_generate.png
--------------------------------------------------------------------------------
/screenshot/auto_record.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RongRongJi/nonebot_plugin_quote/6eccc9909d5401badccaee52599ac6a86fe0ffda/screenshot/auto_record.jpg
--------------------------------------------------------------------------------
/screenshot/data.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RongRongJi/nonebot_plugin_quote/6eccc9909d5401badccaee52599ac6a86fe0ffda/screenshot/data.jpg
--------------------------------------------------------------------------------
/screenshot/delete.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RongRongJi/nonebot_plugin_quote/6eccc9909d5401badccaee52599ac6a86fe0ffda/screenshot/delete.jpg
--------------------------------------------------------------------------------
/screenshot/non.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RongRongJi/nonebot_plugin_quote/6eccc9909d5401badccaee52599ac6a86fe0ffda/screenshot/non.jpg
--------------------------------------------------------------------------------
/screenshot/random.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RongRongJi/nonebot_plugin_quote/6eccc9909d5401badccaee52599ac6a86fe0ffda/screenshot/random.jpg
--------------------------------------------------------------------------------
/screenshot/select.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RongRongJi/nonebot_plugin_quote/6eccc9909d5401badccaee52599ac6a86fe0ffda/screenshot/select.jpg
--------------------------------------------------------------------------------
/screenshot/tag.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RongRongJi/nonebot_plugin_quote/6eccc9909d5401badccaee52599ac6a86fe0ffda/screenshot/tag.jpg
--------------------------------------------------------------------------------
/screenshot/upload.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RongRongJi/nonebot_plugin_quote/6eccc9909d5401badccaee52599ac6a86fe0ffda/screenshot/upload.jpg
--------------------------------------------------------------------------------
/screenshot/usetag.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RongRongJi/nonebot_plugin_quote/6eccc9909d5401badccaee52599ac6a86fe0ffda/screenshot/usetag.jpg
--------------------------------------------------------------------------------