├── .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 | NoneBotPluginLogo 3 |
4 |

NoneBotPluginText

5 |
6 | 7 |
8 | 9 | # nonebot-plugin-quote 10 | 11 | _✨ QQ群聊 语录库 ✨_ 12 | 13 | 🧬 支持OCR识别,关键词搜索 | 一起记录群友的逆天言论吧!🎉 14 | 15 |

16 | license 17 | Python 18 | NoneBot 19 | 20 | pypi 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 | [![contributors](https://contrib.rocks/image?repo=RongRongJi/nonebot_plugin_quote)](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 | ![Example result](https://jay.has-no-bra.in/f/j4iEcc.png) 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 | ![results](https://jay.has-no-bra.in/f/suPfj0.png) 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 --------------------------------------------------------------------------------