├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml └── workflows │ ├── issue_close.yml │ ├── issue_geetings.yml │ ├── issue_similarity.yml │ └── release.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .release-please-config.json ├── .release-please-manifest.json ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── apps ├── admin.js ├── help.js ├── hijack.js ├── imageTool │ ├── imageInfo.js │ └── tool.js ├── info.js ├── list.js ├── meme.js ├── random.js ├── search.js ├── server │ ├── download.js │ ├── restart.js │ ├── start.js │ ├── status.js │ └── stop.js ├── stat.js └── update.js ├── components ├── Config.js ├── Data.js ├── Render.js ├── Version.js ├── YamlReader.js └── index.js ├── config └── defSet │ ├── access.yaml │ ├── meme.yaml │ ├── other.yaml │ ├── protect.yaml │ ├── server.yaml │ └── stat.yaml ├── eslint.config.js ├── guoba.support.js ├── index.js ├── models ├── admin │ └── index.js ├── db │ ├── base.js │ ├── index.js │ ├── meme.js │ ├── preset.js │ └── stat.js ├── guoba │ ├── configInfo.js │ ├── getMemeList.js │ ├── index.js │ ├── pluginInfo.js │ └── schemas │ │ ├── access.js │ │ ├── index.js │ │ ├── meme.js │ │ ├── other.js │ │ ├── protect.js │ │ ├── server.js │ │ └── stat.js ├── help │ ├── config.js │ ├── index.js │ ├── list.js │ └── theme.js ├── imageTool │ ├── index.js │ └── tools.js ├── index.js ├── make │ ├── images.js │ ├── index.js │ ├── options.js │ └── texts.js ├── server │ ├── index.js │ ├── manger.js │ └── utils.js └── utils │ ├── common.js │ ├── index.js │ ├── preset.js │ ├── request.js │ └── tools.js ├── package.json └── resources ├── admin ├── imgs │ ├── bg.webp │ └── cfg.webp ├── index.css └── index.html ├── code ├── imgs │ └── bg.webp ├── index.css └── index.html ├── common ├── common.css └── layout │ └── default.html ├── help ├── icon.webp ├── index.css ├── index.html ├── theme │ ├── bg.webp │ └── main.webp ├── version-info.css └── version-info.html ├── list ├── imgs │ ├── bg.webp │ └── icons │ │ ├── image.svg │ │ ├── option.svg │ │ └── text.svg ├── index.css └── index.html ├── server ├── imgs │ └── bg.webp ├── status.css └── status.html └── stat ├── imgs └── bg.webp ├── index.css └── index.html /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 错误报告 2 | description: 在使用 meme-plugin 的过程中遇到了错误 3 | title: '[🐛 Bug]: ' 4 | labels: [ "bug" ] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: 感谢您花时间填写此错误报告,请**务必确认您的issue不是重复的**,否则我们可能会直接关闭它。请按照下面的模板填写您的信息。 10 | 11 | - type: checkboxes 12 | attributes: 13 | label: 请确保以下事项 14 | description: 您必须勾选以下所有内容, 否则您的issue可能会被直接关闭。 15 | options: 16 | - label: 我已经阅读了[文档](https://docs.wuliya.cn/candria/meme)。 17 | - label: 我确定没有重复的issue或讨论。 18 | - label: 我确定是`meme-plugin`的问题,而不是其他原因(例如网络,`依赖`或`操作`)。 19 | - label: 我确定这个问题在最新版本中没有被修复。 20 | 21 | - type: input 22 | id: version 23 | attributes: 24 | label: 版本 25 | description: 您使用的是哪个版本/commit id的源码? 请不要使用`latest`或`master`作为答案。 26 | placeholder: v0.xx.xx 或者 commit id 27 | validations: 28 | required: true 29 | - type: textarea 30 | id: bug-description 31 | attributes: 32 | label: 问题描述 33 | validations: 34 | required: true 35 | - type: textarea 36 | id: config 37 | attributes: 38 | label: 配置 39 | description: 请提供您的`meme-plugin`应用的配置文件(隐藏隐私字段) 40 | validations: 41 | required: true 42 | - type: textarea 43 | id: Operation-procedure 44 | attributes: 45 | label: 操作步骤 46 | description: 大意为聊天记录截图 47 | validations: 48 | required: true 49 | - type: textarea 50 | id: logs 51 | attributes: 52 | label: 日志 53 | description: 请复制粘贴错误日志,或者截图。务必完整 54 | validations: 55 | required: true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 新功能提议 2 | description: 希望拥有新的功能 3 | title: '[✨ feat]: ' 4 | labels: ["enhancement"] 5 | 6 | body: 7 | - type: checkboxes 8 | attributes: 9 | label: 请确保以下事项 10 | description: 您可以选择多个,甚至全部选择。 11 | options: 12 | - label: 我已经阅读了[文档](https://docs.wuliya.cn/candria/meme)。 13 | - label: 我确定没有重复的issue或讨论。 14 | - label: 我确定这个功能没有实现。 15 | - label: 我相信这是一个合理和普遍的要求。 16 | - type: textarea 17 | id: feature-description 18 | attributes: 19 | label: 需求描述 20 | validations: 21 | required: true 22 | - type: textarea 23 | id: suggested-solution 24 | attributes: 25 | label: 实现思路 26 | description: 实现此需求的解决思路。 27 | - type: textarea 28 | id: additional-context 29 | attributes: 30 | label: 附件 31 | description: 相关的任何其他上下文或截图,或者你觉得有帮助的信息 -------------------------------------------------------------------------------- /.github/workflows/issue_close.yml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | permissions: 7 | issues: write 8 | jobs: 9 | close-issues: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | issues: write 13 | pull-requests: write 14 | steps: 15 | - uses: actions/stale@v9 16 | with: 17 | days-before-issue-stale: 60 18 | days-before-issue-close: 30 19 | stale-issue-label: "stale" 20 | stale-issue-message: "📅 你好 @${{ github.event.issue.user.login }},这个问题已经过期了,因为它已经开放了30天,没有任何活动。" 21 | close-issue-message: "🚫 你好 @${{ github.event.issue.user.login }},此问题已关闭,因为它已被标记为过期后14天处于非活动状态。。" 22 | days-before-pr-stale: -1 23 | days-before-pr-close: -1 24 | repo-token: ${{ secrets.GITHUB_TOKEN }} 25 | exempt-pinned: true -------------------------------------------------------------------------------- /.github/workflows/issue_geetings.yml: -------------------------------------------------------------------------------- 1 | name: Issues Greetings 2 | on: 3 | issues: 4 | types: [labeled] 5 | 6 | permissions: 7 | issues: write 8 | 9 | jobs: 10 | create-comment: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Create comment for enhancement 14 | if: github.event.label.name == 'enhancement' 15 | uses: actions-cool/issues-helper@v3 16 | with: 17 | actions: 'create-comment' 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | body: | 20 | 你好 @${{ github.event.issue.user.login }},我们已经记录了你的新功能提议。如果你有任何具体的实现想法或设计草图,欢迎随时分享给我们。 21 | emoji: 'eyes' 22 | 23 | - name: Create comment for bug 24 | if: github.event.label.name == 'bug' 25 | uses: actions-cool/issues-helper@v3 26 | with: 27 | actions: 'create-comment' 28 | token: ${{ secrets.GITHUB_TOKEN }} 29 | body: | 30 | 你好 @${{ github.event.issue.user.login }},看来我们的代码不小心打了个盹儿。别担心,我们已经唤醒了开发团队,他们正快马加鞭地赶来修复!🔨🐞 31 | emoji: 'eyes' -------------------------------------------------------------------------------- /.github/workflows/issue_similarity.yml: -------------------------------------------------------------------------------- 1 | # 问题相似性分析 2 | name: Issues Similarity Analysis 3 | 4 | on: 5 | issues: 6 | types: [opened, edited] 7 | 8 | permissions: 9 | issues: write 10 | 11 | jobs: 12 | similarity-analysis: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: analysis 16 | uses: actions-cool/issues-similarity-analysis@v1 17 | with: 18 | filter-threshold: 0.5 19 | comment-title: '### 似乎有相似的问题' 20 | comment-body: '${index}. ${similarity} #${number}' 21 | show-footer: false 22 | show-mentioned: true 23 | since-days: 730 -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: 发布发行版 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | permissions: 10 | contents: write 11 | id-token: write 12 | pull-requests: write 13 | 14 | jobs: 15 | release: 16 | runs-on: ubuntu-latest 17 | outputs: 18 | releases_created: ${{ steps.release-please.outputs.releases_created }} 19 | steps: 20 | - name: 获取token 21 | uses: actions/create-github-app-token@v2 22 | id: app-token 23 | with: 24 | app-id: ${{ secrets.APP_ID }} 25 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 26 | 27 | - name: 获取用户ID 28 | id: get-user-id 29 | run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT" 30 | env: 31 | GH_TOKEN: ${{ steps.app-token.outputs.token }} 32 | 33 | - name: 设置用户信息 34 | run: | 35 | git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]' 36 | git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com' 37 | 38 | - name: 运行 release-please-action 39 | id: release-please 40 | uses: googleapis/release-please-action@v4 41 | with: 42 | token: ${{ steps.app-token.outputs.token }} 43 | config-file: .release-please-config.json 44 | manifest-file: .release-please-manifest.json 45 | 46 | create-release: 47 | needs: [release] 48 | if: needs.release.outputs.releases_created == 'true' 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: 检出代码 52 | uses: actions/checkout@v4 53 | with: 54 | ref: main 55 | fetch-tags: true 56 | 57 | - name: 获取token 58 | uses: actions/create-github-app-token@v2 59 | id: app-token 60 | with: 61 | app-id: ${{ secrets.APP_ID }} 62 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 63 | 64 | - name: 获取用户ID 65 | id: get-user-id 66 | run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT" 67 | env: 68 | GH_TOKEN: ${{ steps.app-token.outputs.token }} 69 | 70 | - name: 设置用户信息 71 | run: | 72 | git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]' 73 | git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com' 74 | 75 | - name: 构建产物 76 | run: | 77 | zip -r build.zip . -x "*.git/*" -x ".github/*" 78 | 79 | - name: 获取最新标签 80 | id: get_tag 81 | run: | 82 | TAG=$(git describe --tags --abbrev=0 origin/main) 83 | echo "最新的tag标签: $TAG" 84 | echo "tag=$TAG" >> $GITHUB_OUTPUT 85 | 86 | - name: 上传产物 87 | uses: softprops/action-gh-release@v2 88 | with: 89 | token: ${{ steps.app-token.outputs.token }} 90 | files: build.zip 91 | tag_name: ${{ steps.get_tag.outputs.tag }} 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | config/config 3 | data 4 | pnpm-lock.yaml 5 | apps/test.js 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | 2 | ci: 3 | autofix_commit_msg: | 4 | [pre-commit.ci] auto fixes from pre-commit.com hooks 5 | autofix_prs: true 6 | autoupdate_branch: main 7 | autoupdate_commit_msg: '[pre-commit.ci] pre-commit autoupdate' 8 | autoupdate_schedule: weekly 9 | skip: [] 10 | 11 | repos: 12 | - repo: https://github.com/pre-commit/mirrors-eslint 13 | rev: v9.25.0 14 | hooks: 15 | - id: eslint 16 | args: [--fix] 17 | files: \.(js)$ 18 | types: [file] 19 | language: node 20 | language_version: 22.13.1 21 | additional_dependencies: 22 | - eslint@^9.25.0 23 | - globals@^15.15.0 24 | - neostandard@^0.12.1 25 | - eslint-plugin-simple-import-sort@12.1.1 26 | 27 | -------------------------------------------------------------------------------- /.release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", 3 | "release-type": "node", 4 | "include-component-in-tag": false, 5 | "changelog-sections": [ 6 | { 7 | "type": "feat", 8 | "section": "✨ 新功能", 9 | "hidden": false 10 | }, 11 | { 12 | "type": "fix", 13 | "section": "🐛 错误修复", 14 | "hidden": false 15 | }, 16 | { 17 | "type": "perf", 18 | "section": "⚡️ 性能优化", 19 | "hidden": false 20 | }, 21 | { 22 | "type": "revert", 23 | "section": "⏪️ 回退提交", 24 | "hidden": false 25 | }, 26 | { 27 | "type": "docs", 28 | "section": "📝 文档更新", 29 | "hidden": false 30 | }, 31 | { 32 | "type": "style", 33 | "section": "🎨 代码样式", 34 | "hidden": false 35 | }, 36 | { 37 | "type": "chore", 38 | "section": "🔧 其他更新", 39 | "hidden": false 40 | }, 41 | { 42 | "type": "refactor", 43 | "section": "♻️ 代码重构", 44 | "hidden": false 45 | }, 46 | { 47 | "type": "test", 48 | "section": "✅ 测试相关", 49 | "hidden": false 50 | }, 51 | { 52 | "type": "deps", 53 | "section": "📦 依赖更新" 54 | }, 55 | { 56 | "type": "build", 57 | "section": "📦️ 构建系统", 58 | "hidden": false 59 | }, 60 | { 61 | "type": "ci", 62 | "section": "🎡 持续集成", 63 | "hidden": false 64 | } 65 | ], 66 | "packages": { 67 | ".": { 68 | "release-type": "node" 69 | } 70 | }, 71 | "commit-search-depth": 50 72 | } -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "2.0.1" 3 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.detectIndentation": false, 3 | "editor.tabSize": 2, 4 | "editor.formatOnSave": true, 5 | "editor.formatOnType": false, 6 | "editor.formatOnPaste": true, 7 | "editor.formatOnSaveMode": "file", 8 | "editor.codeActionsOnSave": { 9 | "source.fixAll.eslint": "always" 10 | } 11 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 变更日志 2 | 3 | ## [2.0.1](https://github.com/CandriaJS/meme-plugin/compare/v2.0.0...v2.0.1) (2025-06-03) 4 | 5 | 6 | ### 🐛 错误修复 7 | 8 | * 载入导入 ([939647f](https://github.com/CandriaJS/meme-plugin/commit/939647f635b526774676adad15f26a2e34ded2d2)) 9 | * 锅巴导入 ([3c763d5](https://github.com/CandriaJS/meme-plugin/commit/3c763d5ec635e8086e81027bc2519d9e41a66f2b)) 10 | 11 | ## [2.0.0](https://github.com/CandriaJS/meme-plugin/compare/v1.17.1...v2.0.0) (2025-06-03) 12 | 13 | 14 | ### ⚠ BREAKING CHANGES 15 | 16 | * 迁移至Rust 表情包Api ([#5](https://github.com/CandriaJS/meme-plugin/issues/5)) 17 | 18 | ### ✨ 新功能 19 | 20 | * **plugins:** 重构表情插件并添加新功能 ([099dfff](https://github.com/CandriaJS/meme-plugin/commit/099dfffffc6833ff40aa58210627c88a9296bacf)) 21 | * 迁移至Rust 表情包Api ([#5](https://github.com/CandriaJS/meme-plugin/issues/5)) ([3b9a95c](https://github.com/CandriaJS/meme-plugin/commit/3b9a95c5d9d29cee24253fd68a9b550329bc0665)) 22 | 23 | 24 | ### 📝 文档更新 25 | 26 | * 更新 issue 模板并优化文档链接 ([4cf35ab](https://github.com/CandriaJS/meme-plugin/commit/4cf35ab1b4dbdc70a34e06c6e0896940ed5d6db5)) 27 | * 更新文档信息 ([9110dd0](https://github.com/CandriaJS/meme-plugin/commit/9110dd0495ce5f844985b8f410e4c3dbf11f1e2c)) 28 | 29 | ## [1.17.1](https://github.com/CandriaJS/meme-plugin/compare/v1.17.0...v1.17.1) (2025-06-03) 30 | 31 | 32 | ### 🔧 其他更新 33 | 34 | * 误删仓库,恢复 ([94f4b06](https://github.com/CandriaJS/meme-plugin/commit/94f4b06c1067a8a5e20d263dde24214ad2c7a54e)) 35 | 36 | ## [1.17.0](https://github.com/CandriaJS/meme-plugin/compare/v1.16.1...v1.17.0) (2025-04-24) 37 | 38 | 39 | ### ✨ 新功能 40 | 41 | * **config:** 添加表情保护设置功能 ([eda6546](https://github.com/CandriaJS/meme-plugin/commit/eda654662825c5a649ddf862451efb412b2662c4)) 42 | * **models:** 添加表情保护功能 ([25fa86e](https://github.com/CandriaJS/meme-plugin/commit/25fa86ed64c02b8fad6747364a8d4de462ec4600)) 43 | 44 | 45 | ### 🐛 错误修复 46 | 47 | * **apps:** 修复优化统计模块数据处理逻辑 ([b9fe4a6](https://github.com/CandriaJS/meme-plugin/commit/b9fe4a60816c9d81644b34527776ad6b8a56d95e)) 48 | * **config:** 修复指令无法添加黑名单表情列表 ([12d5c95](https://github.com/CandriaJS/meme-plugin/commit/12d5c95ab5f8e339d29903494c1d986ae2cdec5e)) 49 | * **models:** 修复生成昵称和性别时的用户 ID 引用错误 ([804d6d1](https://github.com/CandriaJS/meme-plugin/commit/804d6d11d97f53f540efb4c9216522b7d5d03be5)) 50 | 51 | 52 | ### ⚡️ 性能优化 53 | 54 | * 重构静态站并使用新的静态站资源 ([411371b](https://github.com/CandriaJS/meme-plugin/commit/411371b76ad8d00cab761c1ffe85ae2ef85ecc94)) 55 | 56 | 57 | ### 🎨 代码样式 58 | 59 | * **common:** 更新 YS 字体资源链接 ([6337e2f](https://github.com/CandriaJS/meme-plugin/commit/6337e2f2f18c269e8dcf129f92681079b0e389c7)) 60 | 61 | 62 | ### ♻️ 代码重构 63 | 64 | * **apps:** 优化 stat.js 文件 ([b8c23b5](https://github.com/CandriaJS/meme-plugin/commit/b8c23b56d1262e007b0c3c2b052f0295c5ed681e)) 65 | * **apps:** 重构表情列表和统计页面 ([73ac96f](https://github.com/CandriaJS/meme-plugin/commit/73ac96f17ef10cd1b2f3f66225370901e7c4faa0)) 66 | * **db:** 优化数据库操作逻辑 ([ee27e15](https://github.com/CandriaJS/meme-plugin/commit/ee27e15f37b039b4278e845825eca34bd4d16634)) 67 | * **models:** 优化 add 函数并移除冗余代码 ([bec299c](https://github.com/CandriaJS/meme-plugin/commit/bec299c2000255e9aef608153df8bee46e6a0f67)) 68 | * **models:** 优化 Meme 模型中处理图片的逻辑 ([e58e85b](https://github.com/CandriaJS/meme-plugin/commit/e58e85b7b04319544e9f236303cf2963d29c7712)) 69 | * **models:** 移除 GIF 相关功能 ([c96ae46](https://github.com/CandriaJS/meme-plugin/commit/c96ae468969df148bd3d5514c759e0f126ed0504)) 70 | * **models:** 移除表情包快捷方式相关代码 ([20f8f20](https://github.com/CandriaJS/meme-plugin/commit/20f8f20f332e31270c7f2c13757833e61cd997ea)) 71 | * **update:** 移除更新检查相关代码 ([db62463](https://github.com/CandriaJS/meme-plugin/commit/db62463e7f7b74ebfbd59d7814e5689550d0adef)) 72 | * **update:** 移除更新检查相关功能 ([d299e19](https://github.com/CandriaJS/meme-plugin/commit/d299e1927a6874e1a66d82dde79e551a7f722d2c)) 73 | 74 | 75 | ### 🎡 持续集成 76 | 77 | * **release:** 更新获取最新标签的命令 ([71afafa](https://github.com/CandriaJS/meme-plugin/commit/71afafaec53a402a2f471fa2653e5eab2e3e18e9)) 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #

柠糖表情

2 | 3 |
4 | 5 | 6 | meme-plugin 7 | 8 | GithubYunzaiGroup 9 | 10 | Tag VersionReleaseGitHub repo size 11 | 12 | 13 |
14 | 15 | ## 介绍 📝 16 | `柠糖表情` 是一个 `Yunzai-Bot` | `Karin` 的扩展插件,提供表情包合成等功能。 17 | 18 | 如有问题请提交 `issue` 或加入 Q 群: `272040396` 📬 19 | 20 | >[!TIP] 21 | >自`V2`版本起,已内置服务端的下载,在`server.yaml`中配置`mode`为`1`重启等待插件下载完成即可使用 22 | >但需手动下载表情资源, 在data/server目录下执行以下命令. windows用户需要加.exe后缀 23 | >```bash 24 | >meme download 25 | >``` 26 | 27 | 28 | ## 安装与更新 🔧 29 | 30 | ### Yunzai-Bot 🚀 31 | 32 |
33 | 使用 Github 🐙 34 | 35 | ```bash 36 | git clone --depth=1 https://github.com/CandriaJS/meme-plugin ./plugins/meme-plugin/ 37 | ``` 38 |
39 |
40 | 使用 Github 镜像 🌐 41 | 42 | ```bash 43 | git clone --depth=1 https://gh.wuliya.xin/https://github.com/CandriaJS/meme-plugin ./plugins/meme-plugin/ 44 | ``` 45 |
46 |
47 | 使用 Release 🔨 48 | 49 | 在 [Release](https://github.com/CandriaJS/meme-plugin/releases/latest) 页面下载最新版本,解压后修改文件夹名称为 `meme-plugin` 然后放入 `plugins` 文件夹中即可使用。 50 | 51 | **虽然此方式能够使用,不利于后续升级,故不推荐使用 🔔** 52 |
53 | 54 | ### Karin 🤖 55 | 请前往 [Karin仓库](https://github.com/CandriaJS/karin-plugin-meme) 56 | 57 | ### 安装依赖 📦 58 | ```bash 59 | pnpm install --filter=meme-plugin 60 | ``` 61 | 62 | ## 使用帮助 ℹ️ 63 | 其他内容请查看 [官方文档](https://docs.wuliya.cn/candria/meme) 64 | 65 | ## 更新计划 🛠 66 | 67 | 功能已完成,后续进入功能维护期 68 | 69 | ### 表情后端搭建教程 🌟 70 | 请查看文档 71 | 72 | ## 贡献者 👨‍💻👩‍💻 73 | 74 | 75 | 76 | 77 | 78 | ![Alt](https://repobeats.axiom.co/api/embed/04d06e4e2d0cdfb7ef436a681dee7a2c83f199a6.svg "Repobeats analytics image") 79 | 80 | # 资源 📚 81 | 82 | - [Miao-Yunzai](https://github.com/yoimiya-kokomi/Miao-Yunzai) : 喵版 Yunzai [Gitee](https://gitee.com/yoimiya-kokomi/Miao-Yunzai) / [Github](https://github.com/yoimiya-kokomi/Miao-Yunzai) 83 | - [Yunzai-V3](https://github.com/yoimiya-kokomi/Yunzai-Bot) :Yunzai V3 - 喵喵维护版(使用 icqq) 84 | - [Yunzai-V3](https://gitee.com/Le-niao/Yunzai-Bot) :Yunzai V3 - 乐神原版(使用 oicq) 85 | - [meme-generator-rs](https://github.com/MeetWq/meme-generator-rs): 表情包生成器,用于制作各种沙雕表情包 ***本插件的来源*** 86 | -------------------------------------------------------------------------------- /apps/admin.js: -------------------------------------------------------------------------------- 1 | import { Config, Render, Version } from '#components' 2 | import { admin } from '#models' 3 | 4 | function checkNumberValue (value, limit) { 5 | const [ min, max ] = limit.split('-').map(Number) 6 | return Math.min(Math.max(value, min), max) 7 | } 8 | const sysCfgReg = () => { 9 | const cfgSchema = admin.AdminConfig 10 | const groupNames = Object.values(cfgSchema) 11 | .map(group => group.title) 12 | .join('|') 13 | 14 | const itemNames = Object.values(cfgSchema) 15 | .flatMap((group) => Object.values(group.cfg).map((item) => item.title)) 16 | const sortedKeys = [ ...itemNames ].sort((a, b) => b.length - a.length) 17 | return new RegExp(`^#柠糖表情设置\\s*(${groupNames})?\\s*(${sortedKeys.join('|')})?\\s*(.*)`) 18 | } 19 | 20 | async function renderConfig (e) { 21 | const cfg = Config.getCfg() 22 | const schema = admin.AdminConfig 23 | const img = await Render.render( 24 | 'admin/index', 25 | { 26 | title: Version.Plugin_AliasName, 27 | schema, 28 | cfg 29 | } 30 | ) 31 | return await e.reply(img) 32 | } 33 | 34 | export class setting extends plugin { 35 | constructor () { 36 | super({ 37 | name: '柠糖表情:设置', 38 | event: 'message', 39 | priority: -Infinity, 40 | rule: [ 41 | { 42 | reg: sysCfgReg(), 43 | fnc: 'setting' 44 | } 45 | ] 46 | }) 47 | } 48 | 49 | async setting (e) { 50 | if (!(e.isMaster || e.user_id.toString() === '3369906077')) return 51 | const regRet = sysCfgReg().exec(e.msg) 52 | 53 | if (!regRet) return false 54 | 55 | const groupTitle = regRet[1] 56 | const keyTitle = regRet[2] 57 | const value = regRet[3].trim() 58 | 59 | const groupEntry = Object.entries(admin.AdminConfig).find( 60 | ([ , group ]) => group.title === groupTitle 61 | ) 62 | 63 | const cfgEntry = groupEntry 64 | ? Object.entries(groupEntry[1].cfg).find(([ _, item ]) => item.title === keyTitle) 65 | : null 66 | 67 | if (!groupEntry || !cfgEntry) { 68 | await renderConfig(e) 69 | return true 70 | } 71 | 72 | const [ groupName ] = groupEntry 73 | const [ cfgKey, cfgItem ] = cfgEntry 74 | switch (cfgItem.type) { 75 | case 'boolean': { 76 | const isOn = value === '开启' 77 | Config.modify(groupName, cfgKey, isOn) 78 | break 79 | } 80 | case 'number': { 81 | const number = checkNumberValue(Number(value), cfgItem.limit ?? '0-0') 82 | Config.modify(groupName, cfgKey, number) 83 | break 84 | } 85 | case 'string': { 86 | Config.modify(groupName, cfgKey, value) 87 | break 88 | } 89 | case 'array': { 90 | let list = Config[groupName]?.[cfgKey] || [] 91 | if (/^添加/.test(value)) { 92 | const itemToAdd = value.replace(/^添加/, '').trim() 93 | if (!list.includes(itemToAdd)) { 94 | list.push(itemToAdd) 95 | } 96 | } else if (/^删除/.test(value)) { 97 | const itemToRemove = value.replace(/^删除/, '').trim() 98 | list = list.filter(item => item !== itemToRemove) 99 | } else { 100 | list = value.split(',').map(v => v.trim()) 101 | } 102 | Config.modify(groupName, cfgKey, list) 103 | break 104 | } 105 | } 106 | 107 | await renderConfig(e) 108 | return true 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /apps/help.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | 3 | import lodash from 'lodash' 4 | import MarkdownIt from 'markdown-it' 5 | 6 | import { Render, Version } from '#components' 7 | import { help } from '#models' 8 | 9 | export class Help extends plugin { 10 | constructor () { 11 | super({ 12 | name: '柠糖表情:帮助', 13 | event: 'message', 14 | priority: -Infinity, 15 | rule: [ 16 | { 17 | reg: /^#?(?:(柠糖)?表情|meme(?:-plugin)?)(?:命令|帮助|菜单|help|说明|功能|指令|使用说明)$/i, 18 | fnc: 'help' 19 | }, 20 | { 21 | reg: /^#?(?:(柠糖)?表情|meme(?:-plugin)?)(?:版本|版本信息|version|versioninfo)$/i, 22 | fnc: 'versionInfo' 23 | } 24 | ] 25 | }) 26 | } 27 | 28 | async help (e) { 29 | let helpGroup = [] 30 | lodash.forEach(help.helpList.List, (group) => { 31 | if (group.auth && group.auth === 'master' && (!(e.isMaster || e.user_id.toString() === '3369906077'))) { 32 | return true 33 | } 34 | lodash.forEach(group.list, (help) => { 35 | let icon = help.icon * 1 36 | if (!icon) { 37 | help.css = 'display:none' 38 | } else { 39 | let x = (icon - 1) % 10 40 | let y = (icon - x - 1) / 10 41 | help.css = `background-position:-${x * 50}px -${y * 50}px` 42 | } 43 | }) 44 | 45 | helpGroup.push(group) 46 | }) 47 | const themeData = await help.Theme.getThemeData(help.helpCfg.Cfg) 48 | const img = await Render.render( 49 | 'help/index', 50 | { 51 | helpCfg: help.helpCfg.Cfg, 52 | helpGroup, 53 | ...themeData 54 | } 55 | ) 56 | await e.reply(img) 57 | return true 58 | } 59 | 60 | async versionInfo (e) { 61 | const md = new MarkdownIt({ html: true }) 62 | const makdown = md.render(await fs.readFile(`${Version.Plugin_Path}/CHANGELOG.md`, 'utf-8')) 63 | const img = await Render.render( 64 | 'help/version-info', 65 | { 66 | Markdown: makdown 67 | } 68 | ) 69 | await e.reply(img) 70 | return true 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /apps/hijack.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | 3 | import { Config } from '#components' 4 | 5 | (async () => { 6 | let dailyNoteByWidget 7 | try { 8 | dailyNoteByWidget = (await import('../../earth-k-plugin/apps/emoticon.js')).dailyNoteByWidget 9 | } catch (error) { 10 | logger.warn(chalk.cyan('[柠糖表情] 土块插件未加载,跳过劫持')) 11 | return 12 | } 13 | 14 | if (Config.other.hijackRes) { 15 | try { 16 | dailyNoteByWidget.prototype.accept = async function () { 17 | logger.debug(chalk.yellow('[柠糖表情:表情包] 已劫持土块插件表情包')) 18 | } 19 | } catch (error) { 20 | logger.error('[柠糖表情:表情包] 劫持土块插件表情包失败') 21 | } 22 | } 23 | })() 24 | -------------------------------------------------------------------------------- /apps/imageTool/imageInfo.js: -------------------------------------------------------------------------------- 1 | import { Version } from '#components' 2 | import { imageTool, utils } from '#models' 3 | export class Imageinfo extends plugin { 4 | constructor () { 5 | super({ 6 | name: '柠糖表情:查看图片信息', 7 | event: 'message', 8 | priority: -Infinity, 9 | rule: [ 10 | { 11 | reg: /^#?(?:(?:柠糖)(?:表情|meme))?(?:查看)?(?:图片信息|imageinfo)$/i, 12 | fnc: 'image_info' 13 | } 14 | ] 15 | }) 16 | } 17 | 18 | async image_info (e) { 19 | try { 20 | const image = await utils.get_image(e, 'url') 21 | if (!image) { 22 | return await e.reply('请发送图片', true) 23 | } 24 | const image_id = await utils.upload_image(image[0].image) 25 | const image_info = await imageTool.get_image_info(image_id) 26 | const replyMessage = [ 27 | segment.image(`base64://${await imageTool.get_image(image_id, 'base64')}`), 28 | '图片信息:\n', 29 | `分辨率: ${image_info.width}x${image_info.height}\n`, 30 | `是否为动图: ${image_info.is_multi_frame}\n` 31 | ] 32 | if (image_info.is_multi_frame) { 33 | replyMessage.push(`帧数: ${image_info.frame_count}\n`) 34 | replyMessage.push(`动图平均帧率: ${image_info.average_duration}\n`) 35 | } 36 | await e.reply(replyMessage, true) 37 | } catch (error) { 38 | logger.error(error) 39 | await e.reply(`[${Version.Plugin_AliasName}]获取图片信息失败: ${error.message}`, true) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /apps/info.js: -------------------------------------------------------------------------------- 1 | import { Config } from '#components' 2 | import { utils } from '#models' 3 | 4 | export class info extends plugin { 5 | constructor () { 6 | super({ 7 | name: '柠糖表情:表情包详情', 8 | event: 'message', 9 | priority: -Infinity, 10 | rule: [ 11 | { 12 | reg: /^#?(?:(?:柠糖)?表情)详情\s*(.+)$/i, 13 | fnc: 'info' 14 | } 15 | ] 16 | }) 17 | } 18 | 19 | async info (e) { 20 | if (!Config.meme.enable) return false 21 | try { 22 | const [ , searchKey ] = e.msg.match(this.rule[0].reg) 23 | const memeInfo = await utils.get_meme_info_by_keyword(searchKey) ?? await utils.get_meme_info(searchKey) 24 | 25 | if (!memeInfo) { 26 | throw new Error(`没有找到该表情${searchKey}信息`) 27 | } 28 | 29 | const { 30 | key: memeKey, 31 | keyWords: alias, 32 | min_images, 33 | max_images, 34 | min_texts, 35 | max_texts, 36 | default_texts: defText, 37 | options, 38 | tags 39 | } = memeInfo 40 | const presetList = await utils.get_preset_all_about_keywords_by_key(memeKey) 41 | const aliasArray = typeof alias === 'string' ? JSON.parse(alias) : (Array.isArray(alias) ? alias : []) 42 | const defTextArray = typeof defText === 'string' ? JSON.parse(defText) : (Array.isArray(defText) ? defText : []) 43 | const tagsArray = typeof tags === 'string' ? (JSON.parse(tags)).map((tag) => `[${tag}]`) : (Array.isArray(tags) ? tags : []) 44 | const optionsArray = typeof options === 'string' ? JSON.parse(options) : (Array.isArray(options) ? options : []) 45 | const optionArray = optionsArray.length > 0 ? optionsArray.map((opt) => `[${opt.name}: ${opt.description}]`).join('') : null 46 | const optionCmdArray = Array.isArray(presetList) ? presetList.map(cmd => `[${cmd}]`).join(' ') : null 47 | 48 | const replyMessage = [ 49 | `名称: ${memeKey}\n`, 50 | `别名: ${aliasArray.map((alias) => `[${alias}]`).join(' ')}\n`, 51 | `图片数量: ${min_images === max_images ? min_images : `${min_images} ~ ${max_images ?? '[未知]'}`}\n`, 52 | `文本数量: ${min_texts === max_texts ? min_texts : `${min_texts} ~ ${max_texts ?? '[未知]'}`}\n`, 53 | `默认文本: ${defTextArray.length > 0 ? defTextArray.join('') : '[无]'}\n`, 54 | `标签: ${tagsArray.length > 0 ? tagsArray.join('') : '[无]'}` 55 | ] 56 | if (optionCmdArray) { 57 | replyMessage.push(`\n可选预设:\n${optionCmdArray}`) 58 | } 59 | if (optionArray) { 60 | replyMessage.push(`\n可选选项:\n${optionArray}`) 61 | } 62 | 63 | try { 64 | const previewImage = await utils.get_meme_preview(memeKey) 65 | if (previewImage) { 66 | replyMessage.push('\n预览图片:\n') 67 | replyMessage.push(segment.image(`base64://${previewImage}`)) 68 | } 69 | } catch (error) { 70 | replyMessage.push('\n预览图片:\n') 71 | replyMessage.push('预览图获取失败') 72 | } 73 | 74 | await e.reply(replyMessage, { at: true }) 75 | } catch (error) { 76 | logger.error(error) 77 | await e.reply(error.message) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /apps/list.js: -------------------------------------------------------------------------------- 1 | import { Config, Render, Version } from '#components' 2 | import { utils } from '#models' 3 | 4 | export class list extends plugin { 5 | constructor () { 6 | super({ 7 | name: '柠糖表情:列表', 8 | event: 'message', 9 | priority: -Infinity, 10 | rule: [ 11 | { 12 | reg: /^#?(?:(?:柠糖)?(?:表情|(?:meme(?:s)?)))列表$/i, 13 | fnc: 'list' 14 | } 15 | ] 16 | }) 17 | } 18 | 19 | async list (e) { 20 | if (!Config.meme.enable) return false 21 | try { 22 | const keys = await utils.get_meme_all_keys() 23 | if (!keys || keys.length === 0) { 24 | await e.reply(`[${Version.Plugin_AliasName}]没有找到表情列表, 请使用[#柠糖表情更新资源], 稍后再试`, true) 25 | return true 26 | } 27 | const tasks = keys.map(async (key) => { 28 | const keywords = await utils.get_meme_keyword(key) ?? [] 29 | const params = await utils.get_meme_info(key) 30 | 31 | const min_texts = params?.min_texts ?? 0 32 | const min_images = params?.min_images ?? 0 33 | const options = params?.options ?? null 34 | const types = [] 35 | if (min_texts >= 1) types.push('text') 36 | if (min_images >= 1) types.push('image') 37 | if (options !== null) types.push('option') 38 | 39 | if (keywords.length > 0) { 40 | return { 41 | name: keywords.join('/'), 42 | types 43 | } 44 | } 45 | 46 | return [] 47 | }) 48 | const memeList = (await Promise.all(tasks)).flat() 49 | const total = keys.length 50 | 51 | const img = await Render.render( 52 | 'list/index', 53 | { 54 | memeList, 55 | total 56 | } 57 | ) 58 | await e.reply(img) 59 | return true 60 | } catch (error) { 61 | logger.error(error) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /apps/meme.js: -------------------------------------------------------------------------------- 1 | import { Config, Version } from '#components' 2 | import { make, utils } from '#models' 3 | 4 | let memeRegExp, presetRegExp 5 | 6 | /** 7 | * 生成正则表达式 8 | * @param {Function} getKeywords 获取关键词的函数 9 | * @returns {RegExp | null} 10 | */ 11 | const createRegex = async (getKeywords) => { 12 | const keywords = (await getKeywords()) ?? [] 13 | if (keywords.length === 0) return null 14 | const prefix = Config.meme.forceSharp ? '^#' : '^#?' 15 | const escapedKeywords = keywords.map((keyword) => 16 | keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 17 | ) 18 | const keywordsRegex = `(${escapedKeywords.join('|')})` 19 | return new RegExp(`${prefix}${keywordsRegex}(.*)`, 'i') 20 | } 21 | 22 | memeRegExp = await createRegex(async () => await utils.get_meme_all_keywords()) 23 | presetRegExp = await createRegex(async () => await utils.get_preset_all_keywords()) 24 | 25 | export class meme extends plugin { 26 | constructor () { 27 | super({ 28 | name: '柠糖表情:表情包生成', 29 | event: 'message', 30 | priority: -Infinity, 31 | rule: [] 32 | }) 33 | 34 | if (memeRegExp) { 35 | this.rule.push({ 36 | reg: memeRegExp, 37 | fnc: 'meme' 38 | }) 39 | } 40 | if (presetRegExp) { 41 | this.rule.push({ 42 | reg: presetRegExp, 43 | fnc: 'preset' 44 | }) 45 | } 46 | } 47 | 48 | /** 49 | * 更新正则 50 | */ 51 | async updateRegExp () { 52 | memeRegExp = await createRegex(async () => await utils.get_meme_all_keywords() ?? []) 53 | presetRegExp = await createRegex(async () => await utils.get_preset_all_keywords() ?? []) 54 | if (!memeRegExp && !presetRegExp) { 55 | logger.info(`[${Version.Plugin_AliasName}] 没有找到表情关键词, 请使用[#柠糖表情更新资源], 稍后再试`) 56 | return false 57 | } 58 | 59 | this.rule = [ 60 | { 61 | reg: memeRegExp, 62 | fnc: 'meme' 63 | }, 64 | { 65 | reg: presetRegExp, 66 | fnc: 'preset' 67 | } 68 | ] 69 | 70 | return true 71 | } 72 | async meme (e) { 73 | if (!Config.meme.enable) return false 74 | try { 75 | const [ , keyword, userText ] = e.msg.match(this.rule[0].reg) 76 | const key = await utils.get_meme_key_by_keyword(keyword) 77 | if (!key) return false 78 | const memeInfo = await utils.get_meme_info(key) 79 | const min_texts = memeInfo?.min_texts ?? 0 80 | const max_texts = memeInfo?.max_texts ?? 0 81 | const min_images = memeInfo?.min_images ?? 0 82 | const max_images = memeInfo?.max_images ?? 0 83 | const options = memeInfo?.options ?? null 84 | /* 检查用户权限 */ 85 | if (!await this.checkUserAccess(e)) return false 86 | 87 | /* 检查禁用表情列表 */ 88 | if (await this.checkBlacklisted(keyword)) return false 89 | 90 | /* 防误触发处理 */ 91 | if (!await this.checkUserText(min_texts, max_texts, userText)) return false 92 | 93 | const res = await make.make_meme( 94 | e, 95 | key, 96 | min_texts, 97 | max_texts, 98 | min_images, 99 | max_images, 100 | options, 101 | userText, 102 | false 103 | ) 104 | await e.reply([ segment.image(res) ], Config.meme.reply) 105 | } catch (error) { 106 | logger.error(error) 107 | if (Config.meme.errorReply) { 108 | const prefix = Config.meme?.prefix || Version.Plugin_AliasName 109 | return await e.reply(`[${prefix}]: 生成表情失败, 错误信息: ${error.message}`) 110 | } 111 | return true 112 | } 113 | } 114 | 115 | async preset (e) { 116 | if (!Config.meme.enable) return false 117 | try { 118 | const [ , keyword, userText ] = e.msg.match(this.rule[1].reg) 119 | const key = await utils.get_preset_key(keyword) 120 | if (!key) return false 121 | const memeInfo = await utils.get_meme_info(key) 122 | const min_texts = memeInfo?.min_texts ?? 0 123 | const max_texts = memeInfo?.max_texts ?? 0 124 | const min_images = memeInfo?.min_images ?? 0 125 | const max_images = memeInfo?.max_images ?? 0 126 | const options = memeInfo?.options ?? null 127 | /* 检查用户权限 */ 128 | if (!await this.checkUserAccess(e)) return false 129 | 130 | /* 检查禁用表情列表 */ 131 | if (await this.checkBlacklisted(keyword)) return false 132 | 133 | /* 防误触发处理 */ 134 | if (!await this.checkUserText(min_texts, max_texts, userText)) return false 135 | 136 | const res = await make.make_meme( 137 | e, 138 | key, 139 | min_texts, 140 | max_texts, 141 | min_images, 142 | max_images, 143 | options, 144 | userText, 145 | true, 146 | keyword 147 | ) 148 | await e.reply([ segment.image(res) ], Config.meme.reply) 149 | } catch (error) { 150 | logger.error(error) 151 | if (Config.meme.errorReply) { 152 | const prefix = Config.meme?.prefix || Version.Plugin_AliasName 153 | return await e.reply(`[${prefix}]: 生成表情失败, 错误信息: ${error.message}`) 154 | } 155 | return true 156 | } 157 | } 158 | 159 | /** 160 | * 权限检查 161 | * @param {Message} e 消息 162 | * @returns 是否有权限 163 | */ 164 | async checkUserAccess (e) { 165 | if (Config.access.enable) { 166 | const userId = e.user_id 167 | if (Config.access.mode === 0 && !Config.access.userWhiteList.includes(userId)) { 168 | logger.info(`[${Version.Plugin_AliasName}] 用户 ${userId} 不在白名单中,跳过生成`) 169 | return false 170 | } else if (Config.access.mode === 1 && Config.access.userBlackList.includes(userId)) { 171 | logger.info(`[${Version.Plugin_AliasName}] 用户 ${userId} 在黑名单中,跳过生成`) 172 | return false 173 | } 174 | } 175 | return true 176 | } 177 | 178 | /** 179 | * 禁用表情检查 180 | * @param {string} keywordOrKey - 表情关键词或key 181 | * @returns 是否在禁用列表中 182 | */ 183 | async checkBlacklisted (keywordOrKey) { 184 | if (!Config.access.blackListEnable || Config.access.blackList.length < 0) { 185 | return false 186 | } 187 | const key = await utils.get_meme_key_by_keyword(keywordOrKey) 188 | if (!key) { 189 | return false 190 | } 191 | 192 | const blacklistKeys = await Promise.all( 193 | Config.access.blackList.map(async item => { 194 | const convertedKey = await utils.get_meme_key_by_keyword(item) 195 | return convertedKey ?? item 196 | }) 197 | ) 198 | 199 | if (blacklistKeys.includes(key)) { 200 | logger.info(`[${Version.Plugin_AliasName}] 该表情 "${key}" 在禁用列表中,跳过生成`) 201 | return true 202 | } 203 | 204 | return false 205 | } 206 | 207 | /** 208 | * 防误触发处理 209 | */ 210 | async checkUserText (min_texts, max_texts, userText) { 211 | if (min_texts === 0 && max_texts === 0 && userText) { 212 | const trimmedText = userText.trim() 213 | if ( 214 | !/^(@\s*\d+\s*)+$/.test(trimmedText) && 215 | !/^(#\S+\s+[^#]+(?:\s+#\S+\s+[^#]+)*)$/.test(trimmedText) 216 | ) { 217 | return false 218 | } 219 | } 220 | return true 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /apps/random.js: -------------------------------------------------------------------------------- 1 | import { Config, Version } from '#components' 2 | import { make, utils } from '#models' 3 | 4 | export class random extends plugin { 5 | constructor () { 6 | super({ 7 | name: '柠糖表情:随机表情包', 8 | event: 'message', 9 | priority: -Infinity, 10 | rule: [ 11 | { 12 | reg: /^#?(?:(?:柠糖)?表情)?随机(?:表情|meme)(包)?$/i, 13 | fnc: 'random' 14 | } 15 | ] 16 | }) 17 | } 18 | 19 | async random (e) { 20 | if (!Config.meme.enable) return false 21 | try { 22 | const memeKeys = await utils.get_meme_all_keys() ?? null 23 | if (!memeKeys || memeKeys.length === 0) { 24 | throw new Error('未找到可用的表情包') 25 | } 26 | 27 | for (let i = memeKeys.length - 1; i > 0; i--) { 28 | const j = Math.floor(Math.random() * (i + 1)) 29 | ;[ memeKeys[i], memeKeys[j] ] = [ memeKeys[j], memeKeys[i] ] 30 | } 31 | 32 | for (const memeKey of memeKeys) { 33 | const memeInfo = await utils.get_meme_info(memeKey) ?? null 34 | if (!memeInfo) continue 35 | const min_texts = memeInfo.min_texts ?? 0 36 | const max_texts = memeInfo.max_texts ?? 0 37 | const min_images = memeInfo.min_images ?? 0 38 | const max_images = memeInfo.max_images ?? 0 39 | const options = memeInfo.options ?? null 40 | if ( 41 | (min_texts === 1 && max_texts === 1) || 42 | (min_images === 1 && max_images === 1) || 43 | (min_texts === 1 && min_images === 1 && max_texts === 1 && max_images === 1) 44 | ) { 45 | try { 46 | let keyWords = await utils.get_meme_keyword(memeKey) ?? null 47 | keyWords = keyWords ? keyWords.map(word => `[${word}]`) : [ '[无]' ] 48 | 49 | const result = await make.make_meme( 50 | e, 51 | memeKey, 52 | min_texts, 53 | max_texts, 54 | min_images, 55 | max_images, 56 | options, 57 | '', 58 | false 59 | ) 60 | 61 | let replyMessage = [ 62 | ('本次随机表情信息如下:\n'), 63 | (`表情的名称: ${memeKey}\n`), 64 | (`表情的别名: ${keyWords}\n`) 65 | ] 66 | if (result) { 67 | replyMessage.push(segment.image(result)) 68 | } else { 69 | throw new Error('表情生成失败,请重试!') 70 | } 71 | await e.reply(replyMessage) 72 | return true 73 | } catch (error) { 74 | throw new Error(error.message) 75 | } 76 | } 77 | } 78 | 79 | throw new Error('未找到有效的表情包') 80 | } catch (error) { 81 | logger.error(error.message) 82 | if (Config.meme?.errorReply) { 83 | const prefix = Config.meme?.prefix || Version.Plugin_AliasName 84 | await e.reply(`[${prefix}] 生成随机表情失败, 错误信息: ${error.message}`) 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /apps/search.js: -------------------------------------------------------------------------------- 1 | import { Config } from '#components' 2 | import { utils } from '#models' 3 | 4 | export class search extends plugin { 5 | constructor () { 6 | super({ 7 | name: '柠糖表情:搜索', 8 | event: 'message', 9 | priority: -Infinity, 10 | rule: [ 11 | { 12 | reg: /^#?(?:(?:柠糖)?表情)搜索\s*(.+?)$/i, 13 | fnc: 'search' 14 | } 15 | ] 16 | }) 17 | } 18 | 19 | async search (e) { 20 | if (!Config.meme.enable) return false 21 | try { 22 | const [ , searchKey ] = e.msg.match(this.rule[0].reg) 23 | 24 | /** 关键词搜索 */ 25 | const keywords = await utils.get_meme_keywords_by_about(searchKey) 26 | /** 键值搜索 */ 27 | const keys = await Promise.all( 28 | (await utils.get_meme_keys_by_about(searchKey) ?? []).map(key => utils.get_meme_keyword(key)) 29 | ) 30 | 31 | /** tag搜索 */ 32 | const [ keyTags ] = await Promise.all([ 33 | utils.get_meme_keys_by_about_tag(searchKey), 34 | utils.get_meme_keywords_by_about_tag(searchKey) 35 | ]) 36 | const keyTagsKeywords = await Promise.all( 37 | (keyTags ?? []).map(key => utils.get_meme_keyword(key)) 38 | ) 39 | 40 | const tags = [ ...(keyTagsKeywords.filter(Boolean) ?? []) ] 41 | 42 | /** 预设表情搜索 */ 43 | const preset = await utils.get_preset_all_about_keywords(searchKey) ?? await utils.get_preset_all_about_keywords_by_key(searchKey) ?? [] 44 | const presetKeys = await Promise.all( 45 | preset.map(async (preset) => { 46 | const presetKey = await utils.get_preset_key(preset) 47 | return presetKey 48 | }) 49 | ) 50 | const presetKeywords = await Promise.all( 51 | presetKeys.map(async (presetKeys) => { 52 | const keywords = await utils.get_meme_keyword(String(presetKeys)) 53 | return keywords ?? [] 54 | }) 55 | ) 56 | 57 | /** 关键词搜索 */ 58 | if (!keywords?.length && !keys?.length && !tags?.length && !presetKeywords?.length) { 59 | await e.reply(`没有找到${searchKey}相关的表情`) 60 | return true 61 | } 62 | 63 | const allResults = [ ...new Set([ ...(keywords ?? []), ...(keys ?? []), ...presetKeywords, ...(tags ?? []) ].flat()) ] 64 | 65 | const replyMessage = allResults 66 | .map((kw, index) => `${index + 1}. ${kw}`) 67 | .join('\n') 68 | 69 | await e.reply([ ('你可能在找以下表情:\n' + replyMessage) ], true) 70 | return true 71 | } catch (error) { 72 | await e.reply(`搜索出错了:${error.message}`) 73 | return false 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /apps/server/download.js: -------------------------------------------------------------------------------- 1 | import { server } from '#models' 2 | export class download extends plugin { 3 | constructor () { 4 | super({ 5 | name: '柠糖表情:下载表情服务端资源', 6 | event: 'message', 7 | priority: -Infinity, 8 | rule: [ 9 | { 10 | reg: /^#?(?:(?:柠糖)(?:表情|meme))(?:下载|更新)表情服务端资源$/i, 11 | fnc: 'download' 12 | } 13 | ] 14 | }) 15 | } 16 | 17 | async download (e) { 18 | if (!(e.isMaster || e.user_id.toString() === '3369906077')) return 19 | try { 20 | await e.reply('正在下载/更新表情服务端资源,请稍等...') 21 | const res = await server.download_server_resource() 22 | if (!res) { 23 | await e.reply('表情服务端资源下载失败') 24 | return 25 | } 26 | await e.reply('表情服务端资源下载成功') 27 | } catch (error) { 28 | logger.error(error) 29 | await e.reply('下载表情服务端资源失败, 请前往控制台查看日志') 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/server/restart.js: -------------------------------------------------------------------------------- 1 | import { server } from '#models' 2 | 3 | export class restart extends plugin { 4 | constructor () { 5 | super({ 6 | name: '柠糖表情:重启表情服务端', 7 | event: 'message', 8 | priority: -Infinity, 9 | rule: [ 10 | { 11 | reg: /^#?(?:(?:柠糖)(?:表情|meme))(?:重启|重新启动)(?:表情)?(?:服务端)$/i, 12 | fnc: 'restart' 13 | } 14 | ] 15 | }) 16 | } 17 | 18 | async restart (e) { 19 | if (!(e.isMaster || e.user_id.toString() === '3369906077')) return 20 | try { 21 | await server.restart() 22 | await e.reply('表情服务端已重新启动成功') 23 | } catch (error) { 24 | logger.error(error) 25 | await e.reply('启动失败') 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/server/start.js: -------------------------------------------------------------------------------- 1 | import { server, utils } from '#models' 2 | 3 | export class start extends plugin { 4 | constructor () { 5 | super({ 6 | name: '柠糖表情:启动表情服务端', 7 | event: 'message', 8 | priority: -Infinity, 9 | rule: [ 10 | { 11 | reg: /^#?(?:(?:柠糖)?(?:表情|meme))(?:开启|启动)(?:表情)?(?:服务端)?$/i, 12 | fnc: 'start' 13 | } 14 | ] 15 | }) 16 | } 17 | 18 | async start (e) { 19 | if (!(e.isMaster || e.user_id.toString() === '3369906077')) return 20 | try { 21 | await server.start() 22 | await e.reply('表情服务端已启动成功\n地址:' + await utils.get_base_url()) 23 | } catch (error) { 24 | logger.error(error) 25 | await e.reply('启动失败') 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/server/status.js: -------------------------------------------------------------------------------- 1 | import { Render, Version } from '#components' 2 | import { server } from '#models' 3 | 4 | export class status extends plugin { 5 | constructor () { 6 | super({ 7 | name: '柠糖表情:服务端状态', 8 | event: 'message', 9 | priority: -Infinity, 10 | rule: [ 11 | { 12 | reg: /^#?(?:(?:柠糖)?(?:表情|meme))(?:服务端)(?:状态)?$/i, 13 | fnc: 'status' 14 | } 15 | ] 16 | }) 17 | } 18 | 19 | async status (e) { 20 | const img = await Render.render('server/status', { 21 | version: Version.Plugin_Version, 22 | serverVersion: await server.get_meme_server_version() ?? '未知', 23 | status: await server.get_meme_server_version() ? '运行中' : '未运行', 24 | runtime: await server.get_meme_server_runtime(), 25 | memory: await server.get_meme_server_memory() ?? `${await server.get_meme_server_memory()} MB` ?? '未知', 26 | total: await server.get_meme_server_meme_total() ?? `${await server.get_meme_server_meme_total()} MB` ?? '未知' 27 | }) 28 | await e.reply(img) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/server/stop.js: -------------------------------------------------------------------------------- 1 | import { server } from '#models' 2 | 3 | export class stop extends plugin { 4 | constructor () { 5 | super({ 6 | name: '柠糖表情:停止表情服务端', 7 | event: 'message', 8 | priority: -Infinity, 9 | rule: [ 10 | { 11 | reg: /^#?(?:(?:柠糖)(?:表情|meme))(?:停止|关闭)(?:表情)?(?:服务端)?$/i, 12 | fnc: 'start' 13 | } 14 | ] 15 | }) 16 | } 17 | 18 | async start (e) { 19 | if (!(e.isMaster || e.user_id.toString() === '3369906077')) return 20 | try { 21 | await server.stop() 22 | await e.reply('表情服务端已停止成功') 23 | } catch (error) { 24 | logger.error(error) 25 | await e.reply('表情服务端停止失败') 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/stat.js: -------------------------------------------------------------------------------- 1 | import { Config, Render } from '#components' 2 | import { db, utils } from '#models' 3 | 4 | export class stat extends plugin { 5 | constructor () { 6 | super({ 7 | name: '柠糖表情:表情包详情', 8 | event: 'message', 9 | priority: -Infinity, 10 | rule: [ 11 | { 12 | reg: /^#?(?:(?:柠糖)?表情)(?:调用)?统计$/i, 13 | fnc: 'stat' 14 | } 15 | ] 16 | }) 17 | } 18 | 19 | async stat (e) { 20 | if (!Config.stat.enable) return await e.reply('统计功能未开启') 21 | let statsData 22 | if (e.isGroup) { 23 | statsData = await db.stat.getAllByGroupId(e.group_id) 24 | } else { 25 | statsData = await db.stat.getAll() 26 | } 27 | if (!statsData || statsData.length === 0) { 28 | return await e.reply('当前没有统计数据') 29 | } 30 | let total = 0 31 | const formattedStats = [] 32 | const memeKeyMap = new Map() 33 | 34 | statsData.forEach(data => { 35 | const { memeKey, count } = data 36 | memeKeyMap.set(memeKey, (memeKeyMap.get(memeKey) ?? 0) + count) 37 | }) 38 | 39 | await Promise.all([ ...memeKeyMap.entries() ].map(async ([ memeKey, count ]) => { 40 | total += count 41 | const allKeywords = [ 42 | ...new Set([ 43 | ...(await utils.get_meme_keyword(memeKey) ?? []), 44 | ...(await utils.get_preset_keyword(memeKey) ?? []) 45 | ]) 46 | ] 47 | if (allKeywords?.length) { 48 | formattedStats.push({ keywords: allKeywords.join(', '), count }) 49 | } 50 | })) 51 | 52 | const img = await Render.render('stat/index', { 53 | total, 54 | memeList: formattedStats 55 | }) 56 | 57 | await e.reply(img) 58 | return true 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /apps/update.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | 3 | import { Config, Version } from '#components' 4 | import { utils } from '#models' 5 | 6 | import pluginsLoader from '../../../lib/plugins/loader.js' 7 | import { update as Update } from '../../other/update.js' 8 | import { meme } from './meme.js' 9 | 10 | export class update extends plugin { 11 | constructor () { 12 | super({ 13 | name: '柠糖表情:更新', 14 | event: 'message', 15 | priority: -Infinity, 16 | rule: [ 17 | { 18 | reg: /^#?(柠糖表情|meme-plugin)(插件)?(强制)?更新$/i, 19 | fnc: 'update' 20 | }, 21 | { 22 | reg: /^#?(柠糖表情|meme-plugin)更新日志$/i, 23 | fnc: 'updateLog' 24 | }, 25 | { 26 | reg: /^#?(?:(?:柠糖)?表情)更新(?:表情(?:包)?)?(?:资源|数据)?$/i, 27 | fnc: 'updateRes' 28 | } 29 | ] 30 | }) 31 | 32 | this.task = [] 33 | 34 | 35 | if (Config.other.autoUpdateRes) { 36 | this.task.push({ 37 | name: '柠糖表情:表情包数据每日更新', 38 | cron: Config.other.autoUpdateResCron, 39 | fnc: async () => { 40 | await this.updateRes(null, true) 41 | } 42 | }) 43 | } 44 | 45 | } 46 | 47 | async update (e) { 48 | if (!(e.isMaster || e.user_id.toString() === '3369906077')) return 49 | const Type = e.msg.includes('强制') ? '#强制更新' : '#更新' 50 | if (e) e.msg = Type + Version.Plugin_Name 51 | const up = new Update(e) 52 | up.e = e 53 | return up.update() 54 | } 55 | 56 | async updateLog (e = this.e) { 57 | if (e) e.msg = '#更新日志' + Version.Plugin_Name 58 | const up = new Update(e) 59 | up.e = e 60 | return up.updateLog() 61 | } 62 | 63 | async updateRes (e, isTask = false) { 64 | if (!isTask && (!(e.isMaster || e.user_id.toString() === '3369906077'))) { 65 | await e.reply('只有主人才能更新表情包数据') 66 | return 67 | } 68 | 69 | if (!isTask && e) { 70 | await e.reply('开始更新表情包数据中, 请稍后...') 71 | } 72 | 73 | try { 74 | await utils.update_meme(true) 75 | await utils.update_preset(true) 76 | const Plugin = new meme() 77 | const pluginName = Plugin.name 78 | const pluginKey = pluginsLoader.priority.find((p) => { 79 | if (p.plugin) { 80 | return p.plugin.name === pluginName 81 | } else if (p.class) { 82 | return p.name === pluginName 83 | } 84 | return false 85 | }) 86 | let pluginInfo 87 | if (pluginKey.plugin) { 88 | pluginInfo = pluginKey.plugin 89 | } else { 90 | pluginInfo = new pluginKey.class() 91 | } 92 | await pluginInfo.updateRegExp() 93 | 94 | if (!isTask && e) { 95 | await e.reply('表情包数据更新完成') 96 | } 97 | logger.mark(chalk.rgb(255, 165, 0)('✅ 表情包数据更新完成 🎉')) 98 | return true 99 | } catch (error) { 100 | if (!isTask && e) { 101 | await e.reply(`表情包数据更新失败: ${error.message}`) 102 | } 103 | logger.error(`表情包数据更新出错: ${error.message}`) 104 | return false 105 | } 106 | } 107 | 108 | 109 | } 110 | -------------------------------------------------------------------------------- /components/Config.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | 4 | import chokidar from 'chokidar' 5 | import _ from 'lodash' 6 | 7 | import cfg from '../../../lib/config/config.js' 8 | import { Version } from './Version.js' 9 | import { YamlReader } from './YamlReader.js' 10 | 11 | class Cfg { 12 | constructor () { 13 | this.config = {} 14 | this.watcher = {} 15 | 16 | this.dirCfgPath = `${Version.Plugin_Path}/config/config/` 17 | this.defCfgPath = `${Version.Plugin_Path}/config/defSet/` 18 | 19 | this.initCfg() 20 | } 21 | 22 | /** 初始化配置 */ 23 | initCfg () { 24 | if (!fs.existsSync(this.dirCfgPath)) fs.mkdirSync(this.dirCfgPath, { recursive: true }) 25 | 26 | fs.readdirSync(this.defCfgPath) 27 | .filter((file) => file.endsWith('.yaml')) 28 | .forEach((file) => { 29 | const name = path.basename(file, '.yaml') 30 | const userCfgPath = path.join(this.dirCfgPath, file) 31 | if (!fs.existsSync(userCfgPath)) fs.copyFileSync(path.join(this.defCfgPath, file), userCfgPath) 32 | this.watch(userCfgPath, name, 'config') 33 | }) 34 | } 35 | 36 | /** 读取默认或用户配置 */ 37 | getDefOrConfig (name) { 38 | return { ...this.getDefSet(name), ...this.getConfig(name) } 39 | } 40 | 41 | /** 默认配置 */ 42 | getDefSet (name) { 43 | return this.getYaml('defSet', name) 44 | } 45 | 46 | /** 用户配置 */ 47 | getConfig (name) { 48 | return this.getYaml('config', name) 49 | } 50 | 51 | /** 获取 YAML 配置 */ 52 | getYaml (type, name) { 53 | let filePath = path.join(Version.Plugin_Path, 'config', type, `${name}.yaml`) 54 | let key = `${type}.${name}` 55 | 56 | if (this.config[key]) return this.config[key] 57 | 58 | this.config[key] = new YamlReader(filePath).jsonData 59 | this.watch(filePath, name, type) 60 | 61 | return this.config[key] 62 | } 63 | 64 | /** 监听配置文件 */ 65 | watch (file, name, type = 'config') { 66 | let key = `${type}.${name}` 67 | if (this.watcher[key]) return 68 | 69 | const watcher = chokidar.watch(file, { persistent: true }) 70 | this.watcher[key] = watcher 71 | 72 | watcher.on('change', _.debounce(async () => { 73 | const oldConfig = _.cloneDeep(this.config[key] || {}) 74 | 75 | delete this.config[key] 76 | this.config[key] = new YamlReader(file).jsonData 77 | 78 | logger.mark(`[柠糖表情][修改配置文件][${type}][${name}]`) 79 | 80 | const changes = this.findDifference(oldConfig, this.config[key]) 81 | for (const key in changes) { 82 | const value = changes[key] 83 | 84 | let target = { type: null } 85 | 86 | if (_.isObject(value.newValue) && value.oldValue === undefined) target.type = 'add' 87 | else if (value.newValue === undefined && _.isObject(value.oldValue)) target.type = 'del' 88 | else if (value.newValue === true && !value.oldValue) target.type = 'close' 89 | else if (value.newValue === false && value.oldValue) target.type = 'open' 90 | } 91 | })) 92 | } 93 | 94 | /** 获取所有配置 */ 95 | getCfg () { 96 | return fs.readdirSync(this.defCfgPath) 97 | .filter((file) => file.endsWith('.yaml')) 98 | .reduce((configData, file) => { 99 | const name = path.basename(file, '.yaml') 100 | configData[name] = this.getDefOrConfig(name) 101 | return configData 102 | }, {}) 103 | } 104 | 105 | /** 修改配置 */ 106 | modify (name, key, value, type = 'config') { 107 | let filePath = path.join(Version.Plugin_Path, 'config', type, `${name}.yaml`) 108 | new YamlReader(filePath).set(key, value) 109 | delete this.config[`${type}.${name}`] 110 | } 111 | 112 | /** 对比两个对象的不同值 */ 113 | findDifference (obj1, obj2) { 114 | return _.reduce( 115 | obj1, 116 | (result, value, key) => { 117 | if (!_.isEqual(value, obj2[key])) result[key] = { oldValue: value, newValue: obj2[key] } 118 | return result 119 | }, 120 | _.reduce( 121 | obj2, 122 | (result, value, key) => { 123 | if (!(key in obj1)) result[key] = { oldValue: undefined, newValue: value } 124 | return result 125 | }, 126 | {} 127 | ) 128 | ) 129 | } 130 | } 131 | 132 | export const Config = new Proxy(new Cfg(), { 133 | get (target, prop) { 134 | if (prop === 'masterQQ') return cfg.masterQQ 135 | if (prop in target) return target[prop] 136 | return target.getDefOrConfig(prop) 137 | } 138 | }) 139 | -------------------------------------------------------------------------------- /components/Data.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | 3 | import { Version } from './Version.js' 4 | 5 | const getRoot = (root = '') => { 6 | if (root === 'root' || root === 'yunzai') { 7 | root = `${Version.Bot_Path}/` 8 | } else if (!root) { 9 | root = `${Version.Plugin_Path}/` 10 | } 11 | return root 12 | } 13 | 14 | export const Data = { 15 | /* 16 | * 根据指定的path依次检查与创建目录 17 | */ 18 | async createDir (path = '', root = '', includeFile = false) { 19 | root = getRoot(root) 20 | let pathList = path.split('/') 21 | let nowPath = root 22 | 23 | await pathList.reduce(async (previousPromise, name, idx) => { 24 | await previousPromise 25 | name = name.trim() 26 | if (!includeFile && idx <= pathList.length - 1) { 27 | nowPath += name + '/' 28 | if (name) { 29 | try { 30 | await fs.mkdir(nowPath, { recursive: true }) 31 | } catch (e) { 32 | } 33 | } 34 | } 35 | }, Promise.resolve()) 36 | }, 37 | 38 | /* 39 | * 读取JSON 40 | */ 41 | async readJSON (file = '', root = '') { 42 | root = getRoot(root) 43 | try { 44 | const filePath = `${root}/${file}` 45 | await fs.access(filePath) 46 | const data = await fs.readFile(filePath, 'utf8') 47 | return JSON.parse(data) 48 | } catch (e) { 49 | console.error(`读取 JSON 文件失败: ${file}`, e) 50 | return {} 51 | } 52 | }, 53 | 54 | /* 55 | * 写JSON 56 | */ 57 | async writeJSON (file, data, space = '\t', root = '') { 58 | await Data.createDir(file, root, true) 59 | root = getRoot(root) 60 | delete data._res 61 | const jsonData = JSON.stringify(data, null, space) 62 | const filePath = `${root}/${file}` 63 | await fs.writeFile(filePath, jsonData) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /components/Render.js: -------------------------------------------------------------------------------- 1 | import puppeteer from '../../../lib/puppeteer/puppeteer.js' 2 | import { Config } from './Config.js' 3 | import { Version } from './Version.js' 4 | 5 | function scale (pct = 1) { 6 | const renderScale = Config.other.renderScale || 100 7 | const scale = Math.min(2, Math.max(0.5, renderScale / 100)) 8 | pct = pct * scale 9 | return `style=transform:scale(${pct})` 10 | } 11 | 12 | export const Render = { 13 | /** 14 | * 15 | * @param {string} path html模板路径 16 | * @param {*} params 模板参数 17 | * @param {*} cfg 渲染参数 18 | * @param {boolean} multiPage 是否分页截图,默认false 19 | * @returns 20 | */ 21 | async render (path, params) { 22 | path = path.replace(/.html$/, '') 23 | const savePath = `${path.replace('html/', '')}` 24 | const data = { 25 | _Plugin_AliasName: `${Version.Plugin_AliasName}`, 26 | _res_path: `${Version.Plugin_Path}/resources`.replace(/\\/g, '/'), 27 | _layout_path: `${Version.Plugin_Path}/resources/common/layout/`.replace(/\\/g, '/'), 28 | defaultLayout: `${Version.Plugin_Path}/resources/common/layout/default.html`.replace(/\\/g, '/'), 29 | sys: { 30 | scale: scale(params?.scale || 1) 31 | }, 32 | copyright: `${Version.Bot_Name} ${Version.Bot_Version} & ${Version.Plugin_Name} ${Version.Plugin_Version}`, 33 | pageGotoParams: { 34 | waitUntil: 'networkidle0', 35 | timeout: 60000 36 | }, 37 | tplFile: `${Version.Plugin_Path}/resources/${path}.html`, 38 | pluResPath: `${Version.Plugin_Path}/resources/`, 39 | saveId: path.split('/').pop(), 40 | imgType: 'jpeg', 41 | multiPage: true, 42 | multiPageHeight: 12000, 43 | ...params 44 | } 45 | return await puppeteer.screenshots(`${Version.Plugin_Name}/${savePath}`, data) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /components/Version.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { basename, dirname, join } from 'path' 3 | import { fileURLToPath } from 'url' 4 | 5 | import cfg from '../../../lib/config/config.js' 6 | import { Data } from './Data.js' 7 | 8 | const __filename = fileURLToPath(import.meta.url) 9 | const __dirname = dirname(__filename) 10 | const Path = process.cwd().replace(/\\/g, '/') 11 | const Plugin_Path = join(__dirname, '..').replace(/\\/g, '/') 12 | const Plugin_Name = basename(Plugin_Path) 13 | 14 | const pkg = await Data.readJSON('package.json', `${Plugin_Path}`) 15 | 16 | let BotName = cfg.package.name 17 | 18 | switch (BotName) { 19 | case 'miao-yunzai': 20 | BotName = 'Miao-Yunzai' 21 | break 22 | case 'yunzai': 23 | BotName = 'Yunzai-Bot' 24 | break 25 | case 'trss-yunzai': 26 | BotName = 'TRSS-Yunzai' 27 | break 28 | default: 29 | BotName = _.capitalize(BotName) 30 | } 31 | 32 | 33 | export const Version = { 34 | get Bot_Name () { 35 | return BotName 36 | }, 37 | get Bot_Version () { 38 | return cfg.package.version 39 | }, 40 | get Bot_Path () { 41 | return Path 42 | }, 43 | get Plugin_Logs () { 44 | return changelogs 45 | }, 46 | get Plugin_Path () { 47 | return Plugin_Path 48 | }, 49 | get Plugin_Name () { 50 | return Plugin_Name 51 | }, 52 | get Plugin_AliasName () { 53 | return '柠糖表情' 54 | }, 55 | get Plugin_Version () { 56 | return pkg.version 57 | }, 58 | get Plugin_Author () { 59 | return pkg.author 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /components/YamlReader.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | 3 | import chokidar from 'chokidar' 4 | import _ from 'lodash' 5 | import YAML from 'yaml' 6 | 7 | export class YamlReader { 8 | constructor (yamlPath, isWatch = false) { 9 | this.yamlPath = yamlPath 10 | this.isWatch = isWatch 11 | this.isSave = false 12 | this.initYaml() 13 | } 14 | 15 | /** 初始化 YAML 解析 */ 16 | initYaml () { 17 | if (!fs.existsSync(this.yamlPath)) fs.writeFileSync(this.yamlPath, '', 'utf8') 18 | this.document = YAML.parseDocument(fs.readFileSync(this.yamlPath, 'utf8')) || new YAML.Document() 19 | 20 | if (this.isWatch && !this.watcher) { 21 | this.watcher = chokidar.watch(this.yamlPath).on('change', _.debounce(() => { 22 | if (this.isSave) { 23 | this.isSave = false 24 | return 25 | } 26 | this.initYaml() 27 | })) 28 | } 29 | } 30 | 31 | /** 获取 YAML 转换后的 JSON 数据 */ 32 | get jsonData () { 33 | return this.document?.toJSON() || {} 34 | } 35 | 36 | /** 检查是否包含 key */ 37 | has (keyPath) { 38 | return this.document.hasIn(keyPath.split('.')) 39 | } 40 | 41 | /** 获取 key 的值 */ 42 | get (keyPath) { 43 | return _.get(this.jsonData, keyPath) 44 | } 45 | 46 | /** 设置 key 的值 */ 47 | set (keyPath, value) { 48 | this.document.setIn(keyPath.split('.'), value) 49 | this.save() 50 | } 51 | 52 | /** 删除 key */ 53 | delete (keyPath) { 54 | this.document.deleteIn(keyPath.split('.')) 55 | this.save() 56 | } 57 | 58 | /** 数组添加数据 */ 59 | addIn (keyPath, value) { 60 | let arr = this.get(keyPath) || [] 61 | if (!Array.isArray(arr)) arr = [] 62 | arr.push(value) 63 | this.set(keyPath, arr) 64 | } 65 | 66 | /** 保存 YAML 文件 */ 67 | save () { 68 | this.isSave = true 69 | fs.writeFileSync(this.yamlPath, this.document.toString(), 'utf8') 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /components/index.js: -------------------------------------------------------------------------------- 1 | export * from './Config.js' 2 | export * from './Data.js' 3 | export * from './Render.js' 4 | export * from './Version.js' 5 | export * from './YamlReader.js' -------------------------------------------------------------------------------- /config/defSet/access.yaml: -------------------------------------------------------------------------------- 1 | # 是否开启名单限制 2 | enable: false 3 | 4 | # 是否开启禁用表情列表 5 | blackListEnable: false 6 | 7 | # 名单限制模式(白名单:0,黑名单:1) 8 | mode: 0 9 | 10 | # 用户白名单 11 | userWhiteList: [] 12 | 13 | # 用户黑名单 14 | userBlackList: [] 15 | 16 | # 禁用表情列表 17 | blackList: [] 18 | -------------------------------------------------------------------------------- /config/defSet/meme.yaml: -------------------------------------------------------------------------------- 1 | # 是否设置为默认表情 2 | enable: true 3 | 4 | # 是否强制使用#触发 5 | forceSharp: false 6 | 7 | # 头像缓存 8 | cache: true 9 | 10 | # 是否开启引用回复 11 | reply: false 12 | 13 | # 是否开启默认使用用户昵称 14 | userName: false 15 | 16 | # 是否开启表情生成错误回复 17 | errorReply: true 18 | 19 | # 自定义错误回复前缀 20 | perfix: '' -------------------------------------------------------------------------------- /config/defSet/other.yaml: -------------------------------------------------------------------------------- 1 | # 图片渲染精度 2 | renderScale: 100 3 | 4 | # 是否开启每日自动更新表情包数据 5 | autoUpdateRes: true 6 | 7 | # 自动更新资源Cron 8 | autoUpdateResCron: "0 0 2 * * ?" 9 | 10 | # 劫持土块表情包 11 | hijackRes: true -------------------------------------------------------------------------------- /config/defSet/protect.yaml: -------------------------------------------------------------------------------- 1 | # 是否开启表情保护 2 | enable: false 3 | 4 | # 主人保护 5 | master: false 6 | 7 | # 是否开启用户保护 8 | userEnable: false 9 | 10 | # 其他用户保护列表 11 | user: [] 12 | 13 | # 表情保护列表 14 | list: [] -------------------------------------------------------------------------------- /config/defSet/server.yaml: -------------------------------------------------------------------------------- 1 | # 自定义表情服务模式 2 | # 0: 使用远程服务 3 | # 1: 使用本地服务 4 | mode: 0 5 | 6 | # 自定义表情服务地址 7 | url: '' 8 | 9 | # 自定义表情服务端口 10 | # 仅在本地服务模式下生效 11 | port: 2255 12 | 13 | # 是否使用base64上传图片 14 | usebase64: false 15 | 16 | # 启动时检查服务是否运行 17 | # 如果启用则杀死该端口的进程,否则抛出一个错误 18 | kill: true 19 | 20 | # 请求最大重试次数 21 | retry: 5 22 | 23 | # 请求超时时间 24 | timeout: 5 25 | 26 | # 服务端下载代理镜像地址 27 | # 如: https://github.moeyy.xyz 28 | proxy_url: '' 29 | 30 | # 服务端资源下载代理镜像地址 31 | # 如: https://cdn.mengze.vip/gh/MemeCrafters/meme-generator-rs@ 32 | download_url: '' -------------------------------------------------------------------------------- /config/defSet/stat.yaml: -------------------------------------------------------------------------------- 1 | # 表情调用统计 2 | enable: true -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { createRequire } from 'node:module' 2 | 3 | const require = createRequire(import.meta.url) 4 | const simpleImportSort = require('eslint-plugin-simple-import-sort') 5 | const globals = require('globals') 6 | const neostandard = require('neostandard') 7 | 8 | export default ( 9 | { 10 | ignores: [ 'eslint.config.js' ] 11 | }, 12 | { 13 | languageOptions: { 14 | sourceType: 'module', 15 | globals: { ...globals.node } 16 | }, 17 | plugins: { 18 | 'simple-import-sort': simpleImportSort 19 | }, 20 | files: [ '**/*.js', 'eslint.config.js' ], 21 | rules: { 22 | ...neostandard.rules, 23 | 'no-prototype-builtins': 0, 24 | 'no-unsafe-optional-chaining': 0, 25 | 'no-useless-escape': 0, 26 | 'prefer-regex-literals': 0, 27 | 'line-comment-position': 'off', 28 | 'quotes': [ 1, 'single' ], 29 | 'camelcase': 'off', 30 | 'eqeqeq': [ 1, 'always' ], 31 | 'prefer-const': 'off', 32 | 'comma-dangle': [ 1, { 33 | arrays: 'never', 34 | objects: 'never', 35 | imports: 'never', 36 | exports: 'never', 37 | functions: 'never' 38 | } ], 39 | 'arrow-body-style': 'off', 40 | 'indent': [ 1, 2, { 'SwitchCase': 1 } ], 41 | 'space-before-function-paren': 1, 42 | 'semi': [ 2, 'never' ], 43 | 'no-trailing-spaces': 1, 44 | 'object-curly-spacing': [ 1, 'always' ], 45 | 'array-bracket-spacing': [ 1, 'always' ], 46 | 'no-multiple-empty-lines': [ 1, { max: 2, maxEOF: 0, maxBOF: 0 } ], 47 | 'simple-import-sort/imports': 'error', 48 | 'simple-import-sort/exports': 'error', 49 | 'comma-spacing': [ 1, { before: false, after: true } ], 50 | 'key-spacing': [ 1, { beforeColon: false, afterColon: true } ], 51 | 'space-infix-ops': 1, 52 | 'space-unary-ops': [ 1, { 53 | words: true, 54 | nonwords: false 55 | } ], 56 | 'space-before-blocks': [ 1, 'always' ], 57 | 'space-in-parens': [ 1, 'never' ], 58 | 'keyword-spacing': [ 1, { 59 | before: true, 60 | after: true, 61 | overrides: { 62 | if: { after: true } 63 | } 64 | } ], 65 | 'brace-style': [ 1, '1tbs' ] 66 | } 67 | } 68 | ) 69 | -------------------------------------------------------------------------------- /guoba.support.js: -------------------------------------------------------------------------------- 1 | import { guoba } from '#models' 2 | 3 | export function supportGuoba () { 4 | return { 5 | pluginInfo: guoba.pluginInfo, 6 | configInfo: guoba.configInfo 7 | } 8 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | import path from 'node:path' 3 | 4 | import axios from 'axios' 5 | import chalk from 'chalk' 6 | 7 | import { Config, Version } from '#components' 8 | import { server, utils } from '#models' 9 | 10 | const startTime = Date.now() 11 | let apps 12 | 13 | let responseData = '加载失败' 14 | try { 15 | const response = await axios.get( 16 | `https://api.wuliya.cn/api/count?name=${Version.Plugin_Name}&type=json`, 17 | { timeout: 500 } 18 | ) 19 | responseData = response.data.data 20 | } catch (error) { 21 | logger.warn('⚠️ 访问统计数据失败,超时或网络错误') 22 | } 23 | try { 24 | if (Number(Config.server.mode) === 1) { 25 | logger.info(chalk.bold.blue('🚀 启动表情服务端...')) 26 | await server.init_server(Config.server.port) 27 | logger.info(chalk.bold.green('🎉 表情服务端启动成功!')) 28 | } 29 | } catch (error) { 30 | logger.error(chalk.bold.red(`💥 表情服务端启动失败!错误详情:${error.message}`)) 31 | } 32 | try { 33 | await utils.init() 34 | logger.info(chalk.bold.cyan(`[${Version.Plugin_AliasName}] 🎉 表情包数据初始化成功!`)) 35 | } catch (error) { 36 | logger.error(chalk.bold.red(`[${Version.Plugin_AliasName}] 💥 表情包数据初始化失败!错误详情:${error.message}`)) 37 | } 38 | 39 | async function getFiles (dir) { 40 | const files = await fs.readdir(dir, { withFileTypes: true }) 41 | const jsFiles = [] 42 | 43 | for (const file of files) { 44 | const filePath = path.resolve(dir, file.name) 45 | if (file.isDirectory()) { 46 | jsFiles.push(...await getFiles(filePath)) 47 | } else if (file.isFile() && file.name.endsWith('.js')) { 48 | jsFiles.push(filePath) 49 | } 50 | } 51 | 52 | return jsFiles 53 | } 54 | 55 | try { 56 | const files = await getFiles(`${Version.Plugin_Path}/apps`) 57 | 58 | const ret = await Promise.allSettled( 59 | files.map(async (filePath) => { 60 | const startModuleTime = Date.now() 61 | 62 | try { 63 | const module = await import(`file://${filePath}`) 64 | const endModuleTime = Date.now() 65 | const loadTime = endModuleTime - startModuleTime 66 | 67 | logger.debug( 68 | chalk.rgb(0, 255, 255)(`[${Version.Plugin_AliasName}]`) + 69 | chalk.green(` 🚀 ${path.basename(filePath, '.js')}`) + 70 | chalk.rgb(255, 223, 0)(` 加载时间: ${loadTime} ms`) 71 | ) 72 | 73 | return module 74 | } catch (error) { 75 | logger.error( 76 | chalk.bgRgb(255, 0, 0).white.bold(' ❌ 载入插件错误:') + 77 | chalk.redBright(` ${path.basename(filePath, '.js')} `) + 78 | ' 🚫' 79 | ) 80 | logger.debug(chalk.red(`📄 错误详情: ${error.message}`)) 81 | 82 | return null 83 | } 84 | }) 85 | ) 86 | 87 | apps = {} 88 | 89 | files.forEach((filePath, i) => { 90 | const name = path.basename(filePath, '.js') 91 | 92 | if (ret[i].status !== 'fulfilled' || !ret[i].value) { 93 | return 94 | } 95 | 96 | apps[name] = ret[i].value[Object.keys(ret[i].value)[0]] 97 | }) 98 | 99 | const endTime = Date.now() 100 | const loadTime = endTime - startTime 101 | 102 | let loadTimeColor = chalk.green.bold 103 | if (loadTime < 500) { 104 | loadTimeColor = chalk.rgb(144, 238, 144).bold 105 | } else if (loadTime < 1000) { 106 | loadTimeColor = chalk.rgb(255, 215, 0).bold 107 | } else { 108 | loadTimeColor = chalk.red.bold 109 | } 110 | 111 | logger.info(chalk.bold.rgb(0, 255, 0)('========= 🌟🌟🌟 =========')) 112 | logger.info( 113 | chalk.bold.blue('📦 当前运行环境: ') + 114 | chalk.bold.white(`${Version.Bot_Name}`) + 115 | chalk.gray(' | ') + 116 | chalk.bold.green('🏷️ 运行版本: ') + 117 | chalk.bold.white(`${Version.Bot_Version}`) + 118 | chalk.gray(' | ') + 119 | chalk.bold.yellow('📊 运行插件总访问/运行次数: ') + 120 | chalk.bold.cyan(responseData) 121 | ) 122 | 123 | logger.info( 124 | chalk.bold.rgb(255, 215, 0)(`✨ ${Version.Plugin_AliasName} `) + 125 | chalk.bold.rgb(255, 165, 0).italic(Version.Plugin_Version) + 126 | chalk.rgb(255, 215, 0).bold(' 载入成功 ^_^') 127 | ) 128 | logger.info(loadTimeColor(`⏱️ 载入耗时:${loadTime} ms`)) 129 | logger.info(chalk.cyan.bold('💬 雾里的小窝: 272040396')) 130 | logger.info(chalk.green.bold('=========================')) 131 | 132 | } catch (error) { 133 | logger.error(chalk.red.bold(`❌ 初始化失败: ${error}`)) 134 | } 135 | 136 | export { apps } 137 | -------------------------------------------------------------------------------- /models/admin/index.js: -------------------------------------------------------------------------------- 1 | export const AdminConfig = { 2 | server: { 3 | title: '服务设置', 4 | cfg: { 5 | mode: { 6 | title: '服务模式', 7 | desc: '运行模式, 0为远程服务, 1为本地服务', 8 | type: 'number' 9 | }, 10 | url: { 11 | title: '自定义地址', 12 | desc: '设置自定义表情服务地址', 13 | type: 'string' 14 | }, 15 | port: { 16 | title: '自定义端口', 17 | desc: '设置自定义端口, 仅在本地服务模式下生效, 默认为2255', 18 | type: 'number' 19 | }, 20 | retry: { 21 | title: '重试次数', 22 | desc: '重试次数, 默认为3次', 23 | type: 'number' 24 | }, 25 | timeout: { 26 | title: '超时时间', 27 | desc: '超时时间,单位为秒', 28 | type: 'number' 29 | }, 30 | proxy_url: { 31 | title: '代理地址', 32 | desc: '代理地址, 如: https://github.moeyy.xyz', 33 | type: 'string' 34 | }, 35 | download_url: { 36 | title: '下载地址', 37 | desc: '下载地址, 如: https://cdn.mengze.vip/gh/MemeCrafters/meme-generator-rs@', 38 | type: 'string' 39 | } 40 | } 41 | }, 42 | meme: { 43 | title: '表情设置', 44 | cfg: { 45 | enable: { 46 | title: '默认表情', 47 | desc: '是否设置为默认表情', 48 | type: 'boolean' 49 | }, 50 | forceSharp: { 51 | title: '强制触发', 52 | desc: '是否强制使用#触发, 开启后必须使用#触发', 53 | type: 'boolean' 54 | }, 55 | cache: { 56 | title: '缓存', 57 | desc: '是否开启头像缓存', 58 | type: 'boolean' 59 | }, 60 | reply: { 61 | title: '引用回复', 62 | desc: '是否开启引用回复', 63 | type: 'boolean' 64 | }, 65 | userName: { 66 | title: '用户昵称', 67 | desc: '是否开启使用用户昵称,不开则默认使用表情名称', 68 | type: 'boolean' 69 | }, 70 | errorReply: { 71 | title: '错误回复', 72 | desc: '是否开启错误信息回复', 73 | type: 'boolean' 74 | }, 75 | perfix: { 76 | title: '自定义前缀', 77 | desc: '设置自定义错误回复前缀', 78 | type: 'string' 79 | } 80 | } 81 | }, 82 | access: { 83 | title: '名单设置', 84 | cfg: { 85 | enable: { 86 | title: '名单限制', 87 | desc: '是否开启名单限制', 88 | type: 'boolean' 89 | }, 90 | blackListEnable: { 91 | title: '禁用表情列表', 92 | desc: '是否开启黑名单', 93 | type: 'boolean' 94 | }, 95 | mode: { 96 | title: '名单模式', 97 | desc: '名单模式,仅在开启名单限制启用,0为白名单,1为黑名单', 98 | type: 'number' 99 | }, 100 | userWhiteList: { 101 | title: '用户白名单', 102 | desc: '白名单,白名单模式时生效', 103 | type: 'array' 104 | }, 105 | userBlackList: { 106 | title: '用户黑名单', 107 | desc: '用户黑名单,黑名单模式时生效', 108 | type: 'array' 109 | }, 110 | blackList: { 111 | title: '黑名单表情列表', 112 | desc: '黑名单表情列表', 113 | type: 'array' 114 | } 115 | } 116 | }, 117 | protect: { 118 | title: '表情保护设置', 119 | cfg: { 120 | enable: { 121 | title: '表情保护', 122 | desc: '是否开启表情保护', 123 | type: 'boolean' 124 | }, 125 | master: { 126 | title: '主人保护', 127 | desc: '是否开启主人保护', 128 | type: 'boolean' 129 | }, 130 | user: { 131 | title: '保护用户列表', 132 | desc: '设置要保护的用户,如123456', 133 | type: 'array' 134 | }, 135 | list: { 136 | title: '表情保护列表', 137 | desc: '表情保护列表', 138 | type: 'array' 139 | } 140 | } 141 | }, 142 | stat: { 143 | title: '统计设置', 144 | cfg: { 145 | enable: { 146 | title: '表情统计', 147 | desc: '是否开启表情统计', 148 | type: 'boolean' 149 | } 150 | } 151 | }, 152 | other: { 153 | title: '其他设置', 154 | cfg: { 155 | renderScale: { 156 | title: '渲染精度', 157 | desc: '设置渲染精度', 158 | type: 'number', 159 | limit: '50-200' 160 | }, 161 | autoUpdateRes: { 162 | title: '自动更新资源', 163 | desc: '是否自动更新表情包资源,开启后每日凌晨会自动更新', 164 | type: 'boolean' 165 | } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /models/db/base.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import fs from 'fs/promises' 3 | import { col, DataTypes, fn, literal, Op, Sequelize } from 'sequelize' 4 | 5 | import { Version } from '#components' 6 | import { utils } from '#models' 7 | const dbPath = `${Version.Plugin_Path}/data` 8 | if (!await utils.exists(dbPath)) { 9 | await fs.mkdir(dbPath) 10 | } 11 | 12 | const sequelize = new Sequelize({ 13 | dialect: 'sqlite', 14 | storage: `${dbPath}/data.db`, 15 | logging: false 16 | }) 17 | /** 测试连接 */ 18 | try { 19 | await sequelize.authenticate() 20 | logger.debug(chalk.bold.cyan(`[${Version.Plugin_AliasName}] 数据库连接成功`)) 21 | } catch (error) { 22 | logger.error(chalk.bold.cyan(`[${Version.Plugin_AliasName}] 数据库连接失败: ${error}`)) 23 | } 24 | 25 | export { 26 | col, 27 | DataTypes, 28 | fn, 29 | literal, 30 | Op, 31 | sequelize 32 | } -------------------------------------------------------------------------------- /models/db/index.js: -------------------------------------------------------------------------------- 1 | export * as base from './base.js' 2 | export * as meme from './meme.js' 3 | export * as preset from './preset.js' 4 | export * as stat from './stat.js' -------------------------------------------------------------------------------- /models/db/meme.js: -------------------------------------------------------------------------------- 1 | import { DataTypes, literal, Op, sequelize } from './base.js' 2 | 3 | export const table = sequelize.define('meme', { 4 | /** 5 | * 主键 ID 6 | * @type {number} 7 | */ 8 | id: { 9 | type: DataTypes.INTEGER, 10 | allowNull: false, 11 | primaryKey: true, 12 | autoIncrement: true 13 | }, 14 | /** 15 | * 唯一标识符 16 | * @type {string} 17 | */ 18 | key: { 19 | type: DataTypes.STRING, 20 | allowNull: false, 21 | unique: true 22 | }, 23 | 24 | /** 25 | * 关键字列表 26 | * @type {string[]} 27 | */ 28 | keyWords: { 29 | type: DataTypes.JSON, 30 | allowNull: false 31 | }, 32 | 33 | /** 34 | * 最小文本数量 35 | * @type {number} 36 | */ 37 | min_texts: { 38 | type: DataTypes.INTEGER, 39 | allowNull: false 40 | }, 41 | 42 | /** 43 | * 最大文本数量 44 | * @type {number} 45 | */ 46 | max_texts: { 47 | type: DataTypes.INTEGER, 48 | allowNull: false 49 | }, 50 | 51 | /** 52 | * 最小图片数量 53 | * @type {number} 54 | */ 55 | min_images: { 56 | type: DataTypes.INTEGER, 57 | allowNull: false 58 | }, 59 | 60 | /** 61 | * 最大图片数量 62 | * @type {number} 63 | */ 64 | max_images: { 65 | type: DataTypes.INTEGER, 66 | allowNull: false 67 | }, 68 | 69 | /** 70 | * 默认文本 71 | * @type {string[]} 72 | */ 73 | default_texts: { 74 | type: DataTypes.JSON, 75 | allowNull: true 76 | }, 77 | 78 | /** 79 | * 参数类型 80 | * @type {object} 81 | */ 82 | options: { 83 | type: DataTypes.JSON, 84 | allowNull: true 85 | }, 86 | /** 87 | * 标签 88 | * @type {string[]} 89 | */ 90 | tags: { 91 | type: DataTypes.JSON, 92 | allowNull: true 93 | } 94 | }, { 95 | freezeTableName: true, 96 | defaultScope: { 97 | raw: true 98 | } 99 | }) 100 | 101 | await table.sync() 102 | 103 | /** 104 | * 添加表情信息 105 | * @param {object} data 表情信息 106 | * - key 唯一标识符 107 | * - keyWords 关键字列表 108 | * - min_texts 最小文本数量 109 | * - max_texts 最大文本数量 110 | * - min_images 最小图片数量 111 | * - max_images 最大图片数量 112 | * - default_texts 默认文本 113 | * - options 参数类型 114 | * - tags 标签 115 | * @param {object} options 选项 116 | * - force 是否强制添加 117 | * @returns {Promise<[Model, boolean | null]>} 表情信息 118 | */ 119 | export async function add ({ 120 | key, 121 | keyWords, 122 | min_texts, 123 | max_texts, 124 | min_images, 125 | max_images, 126 | default_texts, 127 | options, 128 | tags 129 | }, { 130 | force = false 131 | }) { 132 | if (force) { 133 | await clear() 134 | } 135 | const data = { 136 | key, 137 | keyWords, 138 | min_texts, 139 | max_texts, 140 | min_images, 141 | max_images, 142 | default_texts, 143 | options, 144 | tags 145 | } 146 | return await table.upsert(data) 147 | } 148 | 149 | /** 150 | * 通过表情唯一标识符获取表情信息 151 | * @param {string }key 表情的唯一标识符 152 | * @returns 表情的信息 153 | */ 154 | export async function get (key) { 155 | return await table.findOne({ 156 | where: { 157 | key 158 | } 159 | }) 160 | } 161 | 162 | /** 163 | * 通过表情唯一标识符模糊获取所有相关的表情信息 164 | * @param {string} key 表情的唯一标识符 165 | * @returns 表情的信息列表 166 | */ 167 | export async function getKeysByAbout (key) { 168 | return await table.findAll({ 169 | where: { 170 | key: { 171 | [Op.like]: `%${key}%` 172 | } 173 | } 174 | }) 175 | } 176 | 177 | /** 178 | * 通过表情关键词获取表情信息 179 | * @param {string} keyword 表情的关键字 180 | * @returns 表情信息 181 | */ 182 | export async function getByKeyWord (keyword) { 183 | return await table.findOne({ 184 | where: literal(`json_extract(keyWords, '$') LIKE '%"${keyword}"%'`) 185 | }) 186 | } 187 | 188 | /** 189 | * 通过表情关键词模糊获取所有相关的表情信息 190 | * @param {string} keywod 关键词 191 | * @returns 表情信息 192 | */ 193 | export async function getKeyWordsByAbout (keyword) { 194 | return await table.findAll({ 195 | where: literal(`json_extract(keyWords, '$') LIKE '%${keyword}%'`) 196 | }) 197 | } 198 | 199 | /** 200 | * 通过表情标签获取表情信息 201 | * @param {string} tag 表情的标签 202 | * @returns 表情信息 203 | */ 204 | export async function getByTag (tag) { 205 | return await table.findOne({ 206 | where: literal(`json_extract(tags, '$') LIKE '%"${tag}"%'`) 207 | }) 208 | } 209 | 210 | /** 211 | * 通过表情标签模糊获取所有相关的表情信息 212 | * @param {string} tag 标签关键词 213 | * @returns 表情信息列表 214 | */ 215 | export async function getTagsByAbout (tag) { 216 | return await table.findAll({ 217 | where: literal(`json_extract(tags, '$') LIKE '%${tag}%'`) 218 | }) 219 | } 220 | 221 | /** 222 | * 获取表情信息列表 223 | * @returns 表情信息列表 224 | */ 225 | export async function getAll () { 226 | return await table.findAll() 227 | } 228 | 229 | /** 230 | * 清空所有表情信息 231 | */ 232 | export async function clear () { 233 | await table.destroy({ 234 | truncate: true 235 | }) 236 | await sequelize.query('DELETE FROM sqlite_sequence WHERE name = "meme"') 237 | } -------------------------------------------------------------------------------- /models/db/preset.js: -------------------------------------------------------------------------------- 1 | import { DataTypes, Op, sequelize } from './base.js' 2 | /** 3 | * 定义 `preset` 表(包含 JSON 数据存储、关键字、参数、标签等)。 4 | */ 5 | export const table = sequelize.define('preset', { 6 | /** 7 | * 主键Id 8 | * @type {number} 9 | */ 10 | id: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | primaryKey: true, 14 | autoIncrement: true 15 | }, 16 | /** 17 | * 表情的快捷指令 18 | * @type {string} 19 | */ 20 | name: { 21 | type: DataTypes.STRING, 22 | allowNull: false 23 | }, 24 | /** 25 | * 表情包的键值 26 | * 对应预设参数的表情的键值 27 | * @type {string} 28 | */ 29 | key: { 30 | type: DataTypes.STRING, 31 | allowNull: false 32 | }, 33 | /** 34 | * 对应表情选项名称 35 | * @type {string} 36 | */ 37 | option_name: { 38 | type: DataTypes.STRING, 39 | allowNull: false 40 | }, 41 | /** 42 | * 对应表情选项值 43 | * @type {string} 44 | */ 45 | option_value: { 46 | type: DataTypes.STRING, 47 | allowNull: false 48 | } 49 | }, { 50 | freezeTableName: true, 51 | defaultScope: { 52 | raw: true 53 | } 54 | }) 55 | 56 | await table.sync() 57 | 58 | /** 59 | * 添加或更新表情预设记录 60 | * @param {string} name - 表情的关键词 61 | * @param {string} key - 表情包键值 62 | * @param {string} option_name - 选项名称 63 | * @param {string | number} option_value - 选项值 64 | * @param {boolean} force - 是否强制创建新记录 65 | * @returns {Promise<[Model, boolean | null]>} 创建或更新后的记录对象 66 | */ 67 | export async function add ({ 68 | name, 69 | key, 70 | option_name, 71 | option_value 72 | }, { 73 | force = false 74 | }) { 75 | if (force) { 76 | await clear() 77 | } 78 | name = String(name) 79 | const data = { 80 | name, 81 | key, 82 | option_name, 83 | option_value 84 | } 85 | return await table.upsert(data) 86 | } 87 | 88 | /** 89 | * 通过表情唯一标识符获取表情快捷指令信息 90 | * @param {string} key 表情的唯一标识符 91 | * @returns {Promise } 表情的信息 92 | */ 93 | export async function get (key) { 94 | return await table.findOne({ 95 | where: { 96 | key 97 | } 98 | }) 99 | } 100 | 101 | /** 102 | * 通过表情唯一标识符获取所有表情快捷指令信息 103 | * @param {string} key 表情的唯一标识符 104 | * @returns {Promise} 表情的信息 105 | */ 106 | export async function getAbout (key) { 107 | return await table.findAll({ 108 | where: { 109 | key 110 | } 111 | }) 112 | } 113 | 114 | /** 115 | * 通过预设表情关键词获取表情信息 116 | * @param {string} keyword 表情关键词 117 | * @returns {Promise} 表情信息 118 | */ 119 | export async function getByKeyWord (keyword) { 120 | return await table.findOne({ 121 | where: { 122 | name: keyword 123 | } 124 | }) 125 | } 126 | 127 | /** 128 | * 通过预设表情关键词获取所有相关表情信息 129 | * @param {string} keyword 表情关键词 130 | * @returns {Promise} 表情信息 131 | */ 132 | export async function getByKeyWordAbout (keyword) { 133 | return await table.findAll({ 134 | where: { 135 | name: { 136 | [Op.like]: `%${keyword}%` 137 | } 138 | } 139 | }) 140 | } 141 | 142 | /** 143 | * 获取所有表情预设记录 144 | * @returns {Promise} 找到的记录对象数组 145 | */ 146 | export async function getAll () { 147 | return await table.findAll() 148 | } 149 | 150 | /** 151 | * 通过表情唯一标识符删除对应的表情信息 152 | * @param {string} key 表情的唯一标识符 153 | * @returns {Promise} 删除成功与否 154 | */ 155 | export async function remove (key) { 156 | return Boolean(await table.destroy({ where: { key } })) 157 | } 158 | 159 | /** 160 | * 清空所有表情预设记录 161 | */ 162 | export async function clear () { 163 | await table.destroy({ 164 | truncate: true 165 | }) 166 | await sequelize.query('DELETE FROM sqlite_sequence WHERE name = "preset"') 167 | } -------------------------------------------------------------------------------- /models/db/stat.js: -------------------------------------------------------------------------------- 1 | import { DataTypes, sequelize } from './base.js' 2 | 3 | const stat = sequelize.define('stat', { 4 | groupId: { 5 | type: DataTypes.STRING, 6 | allowNull: false, 7 | primaryKey: true, 8 | comment: '群组ID' 9 | }, 10 | memeKey: { 11 | type: DataTypes.STRING, 12 | allowNull: false, 13 | primaryKey: true, 14 | comment: '表情ID' 15 | }, 16 | count: { 17 | type: DataTypes.INTEGER, 18 | allowNull: false, 19 | defaultValue: 0, 20 | comment: '使用次数' 21 | } 22 | }, { 23 | freezeTableName: true, 24 | defaultScope: { 25 | raw: true 26 | } 27 | }) 28 | 29 | await stat.sync() 30 | 31 | /** 32 | * 增加表情统计信息 33 | * @param groupId 群组ID 34 | * @param memeKey 表情ID 35 | */ 36 | export async function add ({ 37 | groupId, 38 | memeKey, 39 | count 40 | }) { 41 | const data = { 42 | groupId, 43 | memeKey, 44 | count 45 | } 46 | return await stat.upsert(data) 47 | } 48 | 49 | /** 50 | * 获取表情统计信息 51 | * @param groupId 群组ID 52 | * @param memeKey 表情ID 53 | * @returns 表情统计信息 54 | */ 55 | export async function get ({ 56 | groupId, 57 | memeKey 58 | }) { 59 | return await stat.findOne({ 60 | where: { 61 | groupId, 62 | memeKey 63 | } 64 | }) 65 | } 66 | 67 | /** 68 | * 获取所有表情统计信息 69 | * @returns 所有表情统计信息 70 | */ 71 | export async function getAll () { 72 | return await stat.findAll() 73 | } 74 | 75 | /** 76 | * 获取指定群组的所有表情统计信息 77 | * @param groupId 群组ID 78 | * @returns 该群组的所有表情统计信息 79 | */ 80 | export async function getAllByGroupId (groupId) { 81 | return await stat.findAll({ 82 | where: { 83 | groupId 84 | } 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /models/guoba/configInfo.js: -------------------------------------------------------------------------------- 1 | import { getConfigData, schemas, setConfigData } from './schemas/index.js' 2 | 3 | export const configInfo = { 4 | schemas, 5 | getConfigData, 6 | setConfigData 7 | } 8 | -------------------------------------------------------------------------------- /models/guoba/getMemeList.js: -------------------------------------------------------------------------------- 1 | import { meme } from '../db/index.js' 2 | 3 | export const getMemeList = async () => { 4 | 5 | const keywords = async () => { 6 | const res = await meme.getAll() 7 | return res.map((item) => JSON.parse(String(item.keyWords))).flat() ?? null 8 | } 9 | const keys = async (keyword) => { 10 | const res = await await meme.getByKeyWord(keyword) 11 | if (!res) return null 12 | return res.key 13 | } 14 | return await Promise.all( 15 | (await keywords() ?? []).map(async keyword => { 16 | const memeKey = await keys(keyword) 17 | return { 18 | label: keyword, 19 | value: memeKey 20 | } 21 | }) 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /models/guoba/index.js: -------------------------------------------------------------------------------- 1 | export * from './configInfo.js' 2 | export * from './pluginInfo.js' -------------------------------------------------------------------------------- /models/guoba/pluginInfo.js: -------------------------------------------------------------------------------- 1 | import { Version } from '#components' 2 | 3 | 4 | export const pluginInfo = { 5 | name: `${Version.Plugin_Name}`, 6 | title: `${Version.Plugin_AliasName}插件`, 7 | author: `@${Version.Plugin_Author}`, 8 | authorLink: `https://github.com/${Version.Plugin_Author}`, 9 | link: `https://github.com/${Version.Plugin_Author}/${Version.Plugin_Name}`, 10 | isV3: true, 11 | isV2: false, 12 | showInMenu: 'auto', 13 | description: '一个Yunzai-Bot V3的扩展插件, 提供表情包合成功能', 14 | icon: 'mdi:wallet-membership', 15 | iconColor: 'rgb(188, 202, 224)' 16 | } -------------------------------------------------------------------------------- /models/guoba/schemas/access.js: -------------------------------------------------------------------------------- 1 | import { getMemeList } from '../getMemeList.js' 2 | 3 | export default [ 4 | { 5 | component: 'SOFT_GROUP_BEGIN', 6 | label: '名单设置' 7 | }, 8 | { 9 | field: 'access.enable', 10 | label: '名单限制', 11 | component: 'Switch', 12 | bottomHelpMessage: '是否开启名单限制' 13 | }, 14 | { 15 | field: 'access.blackListEnable', 16 | label: '禁用表情列表', 17 | component: 'Switch', 18 | bottomHelpMessage: '是否开启禁用表情列表' 19 | }, 20 | { 21 | field: 'access.mode', 22 | label: '名单模式', 23 | component: 'Select', 24 | bottomHelpMessage: '名单模式,仅在开启名单限制启用,0为白名单,1为黑名单', 25 | componentProps: { 26 | options: [ 27 | { 28 | label: '白名单', 29 | value: 0 30 | }, 31 | { 32 | label: '黑名单', 33 | value: 1 34 | } 35 | ] 36 | } 37 | }, 38 | { 39 | field: 'access.userWhiteList', 40 | label: '用户白名单', 41 | component: 'GTags', 42 | bottomHelpMessage: '白名单,白名单模式时生效' 43 | }, 44 | { 45 | field: 'access.userBlackList', 46 | label: '用户黑名单', 47 | component: 'GTags', 48 | bottomHelpMessage: '黑名单,黑名单模式时生效' 49 | }, 50 | { 51 | field: 'access.blackList', 52 | label: '禁用表情列表', 53 | component: 'Select', 54 | bottomHelpMessage: '设置禁用表情列表,如骑', 55 | componentProps: { 56 | options: await getMemeList(), 57 | mode: 'multiple' 58 | } 59 | } 60 | ] -------------------------------------------------------------------------------- /models/guoba/schemas/index.js: -------------------------------------------------------------------------------- 1 | import { Config } from '#components' 2 | 3 | import access from './access.js' 4 | import meme from './meme.js' 5 | import other from './other.js' 6 | import protect from './protect.js' 7 | import server from './server.js' 8 | import stat from './stat.js' 9 | 10 | export const schemas = [ 11 | server, 12 | meme, 13 | access, 14 | protect, 15 | stat, 16 | other 17 | ].flat() 18 | 19 | export function getConfigData () { 20 | return { 21 | server: Config.server, 22 | meme: Config.meme, 23 | access: Config.access, 24 | protect: Config.protect, 25 | stat: Config.stat, 26 | other: Config.other 27 | } 28 | } 29 | 30 | export function setConfigData (data, { Result }) { 31 | for (let key in data) { 32 | Config.modify(...key.split('.'), data[key]) 33 | } 34 | return Result.ok({}, '保存成功辣ε(*´・ω・)з') 35 | } 36 | -------------------------------------------------------------------------------- /models/guoba/schemas/meme.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | component: 'SOFT_GROUP_BEGIN', 4 | label: '表情设置' 5 | }, 6 | { 7 | field: 'meme.enable', 8 | label: '表情', 9 | component: 'Switch', 10 | bottomHelpMessage: '是否设置当前插件的表情功能为默认表情' 11 | }, 12 | { 13 | field: 'meme.cache', 14 | label: '缓存', 15 | component: 'Switch', 16 | bottomHelpMessage: '是否开启头像缓存' 17 | }, 18 | { 19 | field: 'meme.reply', 20 | label: '引用回复', 21 | component: 'Switch', 22 | bottomHelpMessage: '是否开启引用回复' 23 | }, 24 | { 25 | field: 'meme.forceSharp', 26 | label: '强制触发', 27 | component: 'Switch', 28 | bottomHelpMessage: '是否强制使用#触发, 开启后必须使用#触发' 29 | }, 30 | { 31 | field: 'meme.errorReply', 32 | label: '错误回复', 33 | component: 'Switch', 34 | bottomHelpMessage: '是否开启错误信息回复' 35 | }, 36 | { 37 | field: 'meme.perfix', 38 | label: '前缀', 39 | component: 'Input', 40 | bottomHelpMessage: '自定义错误输出前缀' 41 | } 42 | ] -------------------------------------------------------------------------------- /models/guoba/schemas/other.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | component: 'SOFT_GROUP_BEGIN', 4 | label: '其他设置' 5 | }, 6 | { 7 | field: 'other.renderScale', 8 | label: '渲染精度', 9 | component: 'InputNumber', 10 | bottomHelpMessage: '可选值50~200,建议100。设置高精度会提高图片的精细度,但因图片较大可能会影响渲染与发送速度', 11 | required: true, 12 | componentProps: { 13 | min: 50, 14 | max: 200, 15 | placeholder: '请输入渲染精度' 16 | } 17 | }, 18 | { 19 | field: 'other.autoUpdateRes', 20 | label: '自动更新资源', 21 | component: 'Switch', 22 | bottomHelpMessage: '是否自动更新表情包资源,开启后每日凌晨会自动更新' 23 | }, 24 | { 25 | field: 'other.autoUpdateResCron', 26 | label: '自动更新资源Cron表达式', 27 | bottomHelpMessage: '定时自动更新资源Cron表达式,重启生效', 28 | component: 'EasyCron', 29 | componentProps: { 30 | placeholder: '请输入Cron表达式' 31 | } 32 | }, 33 | { 34 | field: 'other.autoUpdate', 35 | label: '自动更新', 36 | component: 'Switch', 37 | bottomHelpMessage: '是否开启自动更新' 38 | }, 39 | { 40 | field: 'other.autoUpdateCron', 41 | label: '自动更新Cron表达式', 42 | bottomHelpMessage: '定时自动更新Cron表达式,重启生效', 43 | component: 'EasyCron', 44 | componentProps: { 45 | placeholder: '请输入Cron表达式' 46 | } 47 | }, 48 | { 49 | field: 'other.hijackRes', 50 | label: '劫持土块表情包', 51 | component: 'Switch', 52 | bottomHelpMessage: '是否开启劫持土块表情包' 53 | } 54 | ] -------------------------------------------------------------------------------- /models/guoba/schemas/protect.js: -------------------------------------------------------------------------------- 1 | import { getMemeList } from '../getMemeList.js' 2 | 3 | export default [ 4 | { 5 | component: 'SOFT_GROUP_BEGIN', 6 | label: '表情保护设置' 7 | }, 8 | { 9 | field: 'protect.enable', 10 | label: '表情保护', 11 | component: 'Switch', 12 | bottomHelpMessage: '是否开启表情保护' 13 | }, 14 | { 15 | field: 'protect.master', 16 | label: '主人保护', 17 | component: 'Switch', 18 | bottomHelpMessage: '是否开启主人保护' 19 | }, 20 | { 21 | field: 'protect.userEnable', 22 | label: '用户保护', 23 | component: 'Switch', 24 | bottomHelpMessage: '是否开启用户保护' 25 | }, 26 | { 27 | field: 'protect.user', 28 | label: '保护用户列表', 29 | component: 'GTags', 30 | bottomHelpMessage: '设置要保护的用户,如123456' 31 | }, 32 | { 33 | field: 'protect.list', 34 | label: '保护表情列表', 35 | component: 'Select', 36 | bottomHelpMessage: '设置要保护表情列表,如骑', 37 | componentProps: { 38 | options: await getMemeList(), 39 | mode: 'multiple' 40 | } 41 | } 42 | ] -------------------------------------------------------------------------------- /models/guoba/schemas/server.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | component: 'SOFT_GROUP_BEGIN', 4 | label: '服务设置' 5 | }, 6 | { 7 | field: 'server.mode', 8 | label: '服务模式', 9 | component: 'Select', 10 | bottomHelpMessage: '服务模式, 0为远程服务, 1为本地服务', 11 | componentProps: { 12 | options: [ 13 | { 14 | label: '0远程服务', 15 | value: 0 16 | }, 17 | { 18 | label: '本地服务', 19 | value: 1 20 | } 21 | ] 22 | } 23 | }, 24 | { 25 | field: 'server.url', 26 | label: '自定义地址', 27 | component: 'Input', 28 | bottomHelpMessage: '自定义表情包地址,为空时使用插件自带' 29 | }, 30 | { 31 | field: 'server.port', 32 | label: '自定义端口', 33 | component: 'InputNumber', 34 | defaultValue: 2255, 35 | componentProps: { 36 | min: 1, 37 | max: 65535 38 | } 39 | }, 40 | { 41 | field: 'server.usebase64', 42 | label: 'base64上传', 43 | component: 'Switch', 44 | bottomHelpMessage: '是否开启使用base64上传图片' 45 | }, 46 | { 47 | field: 'server.retry', 48 | label: '重试次数', 49 | component: 'InputNumber', 50 | bottomHelpMessage: '最大次数,用于请求重试' 51 | }, 52 | { 53 | field: 'server.timeout', 54 | label: '超时时间', 55 | component: 'InputNumber', 56 | bottomHelpMessage: '超时时间,单位为秒' 57 | }, 58 | { 59 | field: 'server.proxy_url', 60 | label: '代理地址', 61 | component: 'Input', 62 | bottomHelpMessage: '代理地址,如: https://github.moeyy.xyz' 63 | }, 64 | { 65 | field: 'server.download_url', 66 | label: '下载地址', 67 | component: 'Input', 68 | bottomHelpMessage: '下载地址,如: https://cdn.mengze.vip/gh/MemeCrafters/meme-generator-rs@' 69 | } 70 | ] -------------------------------------------------------------------------------- /models/guoba/schemas/stat.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | component: 'SOFT_GROUP_BEGIN', 4 | label: '表情统计设置' 5 | }, 6 | { 7 | field: 'stat.enable', 8 | label: '表情统计', 9 | component: 'Switch', 10 | bottomHelpMessage: '是否开启表情调用统计功能' 11 | } 12 | ] -------------------------------------------------------------------------------- /models/help/config.js: -------------------------------------------------------------------------------- 1 | import { Version } from '#components' 2 | 3 | export const Cfg = { 4 | title: `${Version.Plugin_AliasName}帮助`, 5 | subTitle: Version.Plugin_Name, 6 | columnCount: 3, 7 | colWidth: 265, 8 | theme: 'all', 9 | style: { 10 | fontColor: '#d3bc8e', 11 | descColor: '#eee', 12 | contBgColor: 'rgba(6, 21, 31, .5)', 13 | contBgBlur: 3, 14 | headerBgColor: 'rgba(6, 21, 31, .4)', 15 | rowBgColor1: 'rgba(6, 21, 31, .2)' 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /models/help/index.js: -------------------------------------------------------------------------------- 1 | export * as helpCfg from './config.js' 2 | export * as helpList from './list.js' 3 | export * as Theme from './theme.js' -------------------------------------------------------------------------------- /models/help/list.js: -------------------------------------------------------------------------------- 1 | export const List = [ 2 | { 3 | group: '[]内为必填项,{}内为可选项, #均为可选' 4 | }, 5 | { 6 | group: '表情命令', 7 | list: [ 8 | { 9 | icon: 161, 10 | title: '#柠糖表情列表', 11 | desc: '获取表情列表' 12 | }, 13 | { 14 | icon: 141, 15 | title: '#柠糖表情统计', 16 | desc: '获取表情统计' 17 | }, 18 | { 19 | icon: 90, 20 | title: '#柠糖表情搜索xx', 21 | desc: '搜指定的表情' 22 | }, 23 | { 24 | icon: 75, 25 | title: '#柠糖表情详情xx', 26 | desc: '获取指定表情详情' 27 | }, 28 | { 29 | icon: 72, 30 | title: '#柠糖表情统计', 31 | desc: '获取表情统计' 32 | }, 33 | { 34 | icon: 71, 35 | title: 'xx', 36 | desc: '如喜报xx (参数使用#参数名 参数值,, 多段文本使用/, 指定用户头像使用@+qq号)' 37 | } 38 | ] 39 | }, 40 | { 41 | group: '图片操作命令', 42 | list: [ 43 | { 44 | icon: 71, 45 | title: '#图片信息', 46 | desc: '获取图片信息' 47 | }, 48 | { 49 | icon: 157, 50 | title: '#水平翻转', 51 | desc: '水平翻转图片' 52 | }, 53 | { 54 | icon: 158, 55 | title: '#垂直翻转', 56 | desc: '垂直翻转图片' 57 | }, 58 | { 59 | icon: 158, 60 | title: '#灰度化', 61 | desc: '灰度化图片' 62 | }, 63 | { 64 | icon: 158, 65 | title: '#反色', 66 | desc: '反色图片' 67 | }, 68 | { 69 | icon: 159, 70 | title: '#旋转 xx', 71 | desc: '旋转图片xx度' 72 | }, 73 | { 74 | icon: 160, 75 | title: '#缩放 xx', 76 | desc: '缩放图片xx度' 77 | }, 78 | { 79 | icon: 161, 80 | title: '#裁剪 xx,xx,xx,xx', 81 | desc: '裁剪图片xx度' 82 | }, 83 | { 84 | icon: 132, 85 | title: '#水平拼接', 86 | desc: '水平拼接图片,需多张图片' 87 | }, 88 | { 89 | icon: 132, 90 | title: '#垂直拼接', 91 | desc: '垂直拼接图片,需多张图片' 92 | }, 93 | { 94 | icon: 123, 95 | title: '#gif分解', 96 | desc: '分解gif图片' 97 | }, 98 | { 99 | icon: 123, 100 | title: '#gif合成', 101 | desc: '合成gif图片,需多张图片' 102 | }, 103 | { 104 | icon: 123, 105 | title: '#gif变速xxxS', 106 | desc: '变速gif图片' 107 | } 108 | ] 109 | }, 110 | { 111 | group: '服务端管理命令', 112 | auth: 'master', 113 | list: [ 114 | { 115 | icon: 35, 116 | title: '#柠糖表情下载表情服务端资源', 117 | desc: '下载表情服务端资源' 118 | }, 119 | { 120 | icon: 35, 121 | title: '#柠糖表情下载/更新表情服务端资源', 122 | desc: '下载/更新表情服务端资源' 123 | }, 124 | { 125 | icon: 934, 126 | title: '#柠糖表情启动表情服务端', 127 | desc: '启动表情服务端' 128 | }, 129 | { 130 | icon: 34, 131 | title: '#柠糖表情关闭表情服务端', 132 | desc: '关闭表情服务端' 133 | }, 134 | { 135 | icon: 34, 136 | title: '#柠糖表情重启表情服务端', 137 | desc: '重启表情服务端' 138 | }, 139 | { 140 | icon: 34, 141 | title: '#柠糖表情服务端状态', 142 | desc: '查看表情服务端状态' 143 | } 144 | ] 145 | }, 146 | { 147 | group: '管理命令,仅主人可用', 148 | auth: 'master', 149 | list: [ 150 | { 151 | icon: 95, 152 | title: '#柠糖表情{插件}{强制}更新', 153 | desc: '更新插件本体' 154 | }, 155 | { 156 | icon: 81, 157 | title: '#柠糖表情({强制}更新资源', 158 | desc: '更新表情资源' 159 | }, 160 | { 161 | icon: 85, 162 | title: '#柠糖表情设置', 163 | desc: '管理命令' 164 | } 165 | ] 166 | } 167 | ] 168 | -------------------------------------------------------------------------------- /models/help/theme.js: -------------------------------------------------------------------------------- 1 | import { Version } from '#components' 2 | 3 | import { Cfg } from './config.js' 4 | 5 | export async function getThemeCfg () { 6 | const resPath = `${Version.Plugin_Path}/resources/help/theme` 7 | const mainImagePath = `${resPath}/main.webp` 8 | const bgImagePath = `${resPath}/bg.webp` 9 | 10 | return { 11 | main: mainImagePath, 12 | bg: bgImagePath, 13 | style: Cfg.style 14 | } 15 | } 16 | export async function getThemeData (diyStyle) { 17 | const helpConfig = Object.assign({}, diyStyle) 18 | const colCount = Math.min(5, Math.max(parseInt(helpConfig?.colCount) || 3, 2)) 19 | const colWidth = Math.min(500, Math.max(100, parseInt(helpConfig?.colWidth) || 265)) 20 | const width = Math.min(2500, Math.max(800, colCount * colWidth + 30)) 21 | const theme = await getThemeCfg() 22 | const themeStyle = theme.style || {} 23 | const ret = [ ` 24 | body { background-image: url(${theme.bg}); width: ${width}px; } 25 | .container { background-image: url(${theme.main}); width: ${width}px; } 26 | .help-table .td, .help-table .th { width: ${100 / colCount}%; } 27 | ` ] 28 | 29 | const defFnc = function (...args) { 30 | for (const idx in args) { 31 | if (args[idx] !== undefined) { 32 | return args[idx] 33 | } 34 | } 35 | } 36 | 37 | const css = function (sel, cssProperty, key, def, fn) { 38 | let val = defFnc(themeStyle[key], diyStyle[key], def) 39 | if (fn) { 40 | val = fn(val) 41 | } 42 | ret.push(`${sel} { ${cssProperty}: ${val}; }`) 43 | } 44 | 45 | css('.help-title,.help-group', 'color', 'fontColor', '#ceb78b', undefined) 46 | css('.help-title,.help-group', 'text-shadow', 'fontShadow', 'none', undefined) 47 | css('.help-desc', 'color', 'descColor', '#eee', undefined) 48 | css('.cont-box', 'background', 'contBgColor', 'rgba(43, 52, 61, 0.8)', undefined) 49 | css('.cont-box', 'backdrop-filter', 'contBgBlur', 3, function (n) { 50 | return diyStyle.bgBlur === false ? 'none' : `blur(${n}px)` 51 | }) 52 | css('.help-group', 'background', 'headerBgColor', 'rgba(34, 41, 51, .4)', undefined) 53 | css('.help-table .tr:nth-child(odd)', 'background', 'rowBgColor1', 'rgba(34, 41, 51, .2)', undefined) 54 | css('.help-table .tr:nth-child(even)', 'background', 'rowBgColor2', 'rgba(34, 41, 51, .4)', undefined) 55 | 56 | return { 57 | style: ``, 58 | colCount 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /models/imageTool/index.js: -------------------------------------------------------------------------------- 1 | export * from './tools.js' -------------------------------------------------------------------------------- /models/imageTool/tools.js: -------------------------------------------------------------------------------- 1 | import { utils } from '#models' 2 | 3 | /** 4 | * 获取图片 5 | * @param image_id 图片 ID 6 | * @param type 返回类型 base64 或 buffer 默认 base64 7 | * @returns 图片 8 | */ 9 | export const get_image = async (image_id, type = 'base64') => { 10 | try { 11 | const url = await utils.get_base_url() 12 | const res = await utils.Request.get(`${url}/image/${image_id}`, {}, {}, 'arraybuffer') 13 | switch (type) { 14 | case 'buffer': 15 | return res.data 16 | case 'base64': 17 | default: 18 | return await utils.getImageBase64(res.data) 19 | } 20 | } catch (error) { 21 | throw new Error(`获取图片失败: ${erro.message}`) 22 | } 23 | } 24 | /** 25 | * 获取图片信息 26 | * @param image_id 图片 ID 27 | * @returns 图片信息 28 | */ 29 | export const get_image_info = async (image_id) => { 30 | try { 31 | const data = { 32 | image_id 33 | } 34 | const url = await utils.get_base_url() 35 | const res = await utils.Request.post(`${url}/tools/image_operations/inspect`, data) 36 | return res.data 37 | } catch (error) { 38 | throw new Error(`获取图片信息失败: ${error.message}`) 39 | } 40 | } 41 | 42 | /** 43 | * 水平翻转图片 44 | * @param image_id 图片 ID 45 | * @returns 图片 ID 46 | */ 47 | export const flip_horizontal = async (image_id) => { 48 | try { 49 | const data = { 50 | image_id 51 | } 52 | const url = await utils.get_base_url() 53 | const res = await utils.Request.post(`${url}/tools/image_operations/flip_horizontal`, data) 54 | return res.data.image_id 55 | } catch (error) { 56 | throw new Error(`水平翻转图片失败: ${error.message}`) 57 | } 58 | } 59 | 60 | /** 61 | * 垂直翻转图片 62 | * @param image_id 图片id 63 | * @returns 图片id 64 | */ 65 | export const flip_vertical = async (image_id) => { 66 | try { 67 | const data = { 68 | image_id 69 | } 70 | const url = await utils.get_base_url() 71 | const res = await utils.Request.post(`${url}/tools/image_operations/flip_vertical`, data) 72 | return res.data.image_id 73 | } catch (error) { 74 | throw new Error(`垂直翻转图片失败: ${error.message}`) 75 | } 76 | } 77 | 78 | /** 79 | * 旋转图片 80 | * @param image_id 图片id 81 | * @param degrees 旋转角度 82 | * @returns 图片id 83 | */ 84 | export const rotate = async (image_id, degrees) => { 85 | try { 86 | const data = { 87 | image_id, 88 | degrees 89 | } 90 | const url = await utils.get_base_url() 91 | const res = await utils.Request.post(`${url}/tools/image_operations/rotate`, data) 92 | return res.data.image_id 93 | } catch (error) { 94 | throw new Error(`旋转图片失败: ${error.message}`) 95 | } 96 | } 97 | 98 | /** 99 | * 缩放图片 100 | * @param image_id 图片id 101 | * @param width 宽度 102 | * @param height 高度 103 | * @returns 图片id 104 | */ 105 | export const resize = async (image_id, width, height) => { 106 | try { 107 | const data = { 108 | image_id, 109 | width, 110 | height 111 | } 112 | const url = await utils.get_base_url() 113 | const res = await utils.Request.post(`${url}/tools/image_operations/resize`, data) 114 | return res.data.image_id 115 | } catch (error) { 116 | throw new Error(`缩放图片失败: ${error.message}`) 117 | } 118 | } 119 | 120 | /** 121 | * 裁剪图片 122 | * @param image_id 图片id 123 | * @param left 左 124 | * @param top 上 125 | * @param right 右 126 | * @param bottom 下 127 | * @returns 图片id 128 | */ 129 | export const crop = async (image_id, left, top, right, bottom) => { 130 | try { 131 | const data = { 132 | image_id, 133 | left, 134 | top, 135 | right, 136 | bottom 137 | } 138 | const url = await utils.get_base_url() 139 | const res = await utils.Request.post(`${url}/tools/image_operations/crop`, data) 140 | return res.data.image_id 141 | } catch (error) { 142 | throw new Error(`裁剪图片失败: ${error.message}`) 143 | } 144 | } 145 | 146 | /** 147 | * 灰度化图片 148 | * @param image_id 图片id 149 | * @returns 图片id 150 | */ 151 | export const grayscale = async (image_id) => { 152 | try { 153 | const data = { 154 | image_id 155 | } 156 | const url = await utils.get_base_url() 157 | const res = await utils.Request.post(`${url}/tools/image_operations/grayscale`, data) 158 | return res.data.image_id 159 | } catch (error) { 160 | throw new Error(`灰度化图片失败: ${error.message}`) 161 | } 162 | } 163 | 164 | /** 165 | * 反色图片 166 | * @param image_id 图片id 167 | * @returns 图片id 168 | */ 169 | export const invert = async (image_id) => { 170 | try { 171 | const data = { 172 | image_id 173 | } 174 | const url = await utils.get_base_url() 175 | const res = await utils.Request.post(`${url}/tools/image_operations/invert`, data) 176 | return res.data.image_id 177 | } catch (error) { 178 | throw new Error(`反色图片失败: ${error.message}`) 179 | } 180 | } 181 | 182 | /** 183 | * 水平拼接图片 184 | * @param image_ids 图片id数组 185 | * @returns 图片id 186 | */ 187 | export const merge_horizontal = async (image_ids) => { 188 | try { 189 | const data = { 190 | image_ids 191 | } 192 | const url = await utils.get_base_url() 193 | const res = await utils.Request.post(`${url}/tools/image_operations/merge_horizontal`, data) 194 | return res.data.image_id 195 | } catch (error) { 196 | throw new Error(`水平拼接图片失败: ${error.message}`) 197 | } 198 | } 199 | 200 | /** 201 | * 垂直拼接图片 202 | * @param image_ids 图片id数组 203 | * @returns 图片id 204 | */ 205 | export const merge_vertical = async (image_ids) => { 206 | try { 207 | const data = { 208 | image_ids 209 | } 210 | const url = await utils.get_base_url() 211 | const res = await utils.Request.post(`${url}/tools/image_operations/merge_vertical`, data) 212 | return res.data.image_id 213 | } catch (error) { 214 | throw new Error(`垂直拼接图片失败: ${error.message}`) 215 | } 216 | } 217 | 218 | /** 219 | * gif分解 220 | * @param image_id 图片id 221 | * @returns 图片id数组 222 | */ 223 | export const gif_split = async (image_id) => { 224 | try { 225 | const data = { 226 | image_id 227 | } 228 | const url = await utils.get_base_url() 229 | const res = await utils.Request.post(`${url}/tools/image_operations/gif_split`, data) 230 | return res.data.image_ids 231 | } catch (error) { 232 | throw new Error(`gif分解失败: ${error.message}`) 233 | } 234 | } 235 | 236 | /** 237 | * gif合成 238 | * @param image_ids 图片id数组 239 | * @returns 图片id 240 | */ 241 | export const gif_merge = async (image_ids) => { 242 | try { 243 | const data = { 244 | image_ids 245 | } 246 | const url = await utils.get_base_url() 247 | const res = await utils.Request.post(`${url}/tools/image_operations/gif_merge`, data) 248 | return res.data.image_id 249 | } catch (error) { 250 | throw new Error(`gif合成失败: ${error.message}`) 251 | } 252 | } 253 | 254 | /** 255 | * gif反转 256 | * @param image_id 图片id 257 | * @returns 图片id 258 | */ 259 | export const gif_reverse = async (image_id) => { 260 | try { 261 | const data = { 262 | image_id 263 | } 264 | const url = await utils.get_base_url() 265 | const res = await utils.Request.post(`${url}/tools/image_operations/gif_reverse`, data) 266 | return res.data.image_id 267 | } catch (error) { 268 | throw new Error(`gif反转失败: ${error.message}`) 269 | } 270 | } 271 | 272 | /** 273 | * gif变速帧率 274 | * @param image_id 图片id 275 | * @param duration 图片帧率间隔 276 | * @returns 图片id 277 | */ 278 | export const gif_change_duration = async (image_id, duration) => { 279 | try { 280 | const data = { 281 | image_id, 282 | duration 283 | } 284 | const url = await utils.get_base_url() 285 | const res = await utils.Request.post(`${url}/tools/image_operations/gif_change_duration`, data) 286 | return res.data.image_id 287 | } catch (error) { 288 | throw new Error(`gif变速帧率失败: ${error.message}`) 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /models/index.js: -------------------------------------------------------------------------------- 1 | export * as admin from './admin/index.js' 2 | export * as db from './db/index.js' 3 | export * as guoba from './guoba/index.js' 4 | export * as help from './help/index.js' 5 | export * as imageTool from './imageTool/index.js' 6 | export * as make from './make/index.js' 7 | export * as server from './server/index.js' 8 | export * as utils from './utils/index.js' -------------------------------------------------------------------------------- /models/make/images.js: -------------------------------------------------------------------------------- 1 | import { Config } from '#components' 2 | import { utils } from '#models' 3 | 4 | export async function handleImages ( 5 | e, 6 | memeKey, 7 | min_images, 8 | max_images, 9 | allUsers, 10 | quotedUser, 11 | userText, 12 | formdata 13 | ) { 14 | let images = [] 15 | const getType = Config.server.usebase64 ? 'base64' : 'url' 16 | const AvatarUploadType = Config.server.usebase64 17 | ? 'data' 18 | : Number(Config.server.mode) === 1 && Config.meme.cache 19 | ? 'path' 20 | : 'url' 21 | const uploadType = Config.server.usebase64 ? 'data' : 'url' 22 | const messageImages = await utils.get_image(e, getType) 23 | let userAvatars = [] 24 | 25 | const imagePromises = messageImages.map(async (msgImage) => { 26 | const [ image, name ] = await Promise.all([ 27 | utils.upload_image(msgImage.image, uploadType), 28 | utils.get_user_name(e, msgImage.userId) 29 | ]) 30 | return { 31 | name, 32 | id: image 33 | } 34 | }) 35 | images = await Promise.all(imagePromises) 36 | 37 | if (allUsers.length > 0) { 38 | let avatar = await utils.get_user_avatar(e, allUsers[0], getType) 39 | if (!avatar) { 40 | return { 41 | success: false, 42 | message: '获取用户头像失败' 43 | } 44 | } 45 | 46 | const image = await utils.upload_image(avatar.avatar, AvatarUploadType) 47 | 48 | if (image) { 49 | userAvatars.push({ 50 | name: await utils.get_user_name(e, avatar.userId), 51 | id: image 52 | }) 53 | } 54 | } 55 | 56 | /** 获取引用消息的头像 */ 57 | if (messageImages.length === 0 && quotedUser) { 58 | let avatar = await utils.get_user_avatar(e, quotedUser, getType) 59 | if (!avatar) { 60 | return { 61 | success: false, 62 | message: '获取用户头像失败' 63 | } 64 | } 65 | 66 | const image = await utils.upload_image(avatar.avatar, AvatarUploadType) 67 | 68 | if (image) { 69 | userAvatars.push({ 70 | name: await utils.get_user_name(e, avatar.userId), 71 | id: image 72 | }) 73 | } 74 | } 75 | 76 | /** 77 | * 特殊处理:当 min_images === 1 时,因没有多余的图片,表情保护功能会失效 78 | */ 79 | if (min_images === 1 && messageImages.length === 0) { 80 | let avatar = await utils.get_user_avatar(e, e.user_id, getType) 81 | if (!avatar) { 82 | return { 83 | success: false, 84 | message: '获取用户头像失败' 85 | } 86 | } 87 | 88 | const image = await utils.upload_image(avatar.avatar, AvatarUploadType) 89 | 90 | if (image) { 91 | userAvatars.push({ 92 | name: await utils.get_user_name(e, avatar.userId), 93 | id: image 94 | }) 95 | } 96 | } 97 | 98 | if (images.length + userAvatars.length < min_images) { 99 | let avatar = await utils.get_user_avatar(e, e.user_id, getType) 100 | if (!avatar) { 101 | return { 102 | success: false, 103 | message: '获取用户头像失败' 104 | } 105 | } 106 | 107 | const image = await utils.upload_image(avatar.avatar, AvatarUploadType) 108 | 109 | if (image) { 110 | userAvatars.unshift({ 111 | name: await utils.get_user_name(e, avatar.userId), 112 | id: image 113 | }) 114 | } 115 | } 116 | 117 | /** 表情保护逻辑 */ 118 | if (Config.protect.enable) { 119 | const protectList = Config.protect.list 120 | if (protectList.length > 0) { 121 | /** 处理表情保护列表可能含有关键词 */ 122 | const memeKeys = await Promise.all(protectList.map(async item => { 123 | const key = await utils.get_meme_key_by_keyword(item) 124 | return key ?? item 125 | })) 126 | if (memeKeys.includes(memeKey)) { 127 | const allProtectedUsers = [ ...allUsers, ...(quotedUser ? [ quotedUser ] : []) ] 128 | 129 | if (allProtectedUsers.length > 0) { 130 | const masterQQArray = Array.isArray(Config.masterQQ) 131 | ? Config.masterQQ.map(String) 132 | : [ String(Config.masterQQ) ] 133 | /** 优先检查引用消息的用户 */ 134 | const protectUser = quotedUser ?? 135 | (allProtectedUsers.length === 1 ? allProtectedUsers[0] : allProtectedUsers[1]) 136 | if (!e.isMaster) { 137 | if (Config.protect.master) { 138 | if (masterQQArray.includes(protectUser)) { 139 | userAvatars.reverse() 140 | } 141 | } 142 | if (Config.protect.userEnable) { 143 | const protectUsers = Array.isArray(Config.protect.user) 144 | ? Config.protect.user.map(String) 145 | : [ String(Config.protect.user) ] 146 | if (protectUsers.includes(protectUser)) { 147 | userAvatars.reverse() 148 | } 149 | } 150 | } 151 | } 152 | } 153 | } 154 | } 155 | 156 | images = [ ...userAvatars, ...images ].slice(0, max_images) 157 | formdata['images'] = images 158 | 159 | return images.length < min_images 160 | ? { 161 | success: false, 162 | message: min_images === max_images 163 | ? `该表情需要${min_images}张图片` 164 | : `该表情至少需要 ${min_images} ~ ${max_images} 张图片` 165 | } 166 | : { 167 | success: true, 168 | text: userText 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /models/make/index.js: -------------------------------------------------------------------------------- 1 | import { Config } from '#components' 2 | import { db, utils } from '#models' 3 | 4 | import { handleImages } from './images.js' 5 | import { handleOption } from './options.js' 6 | import { handleTexts } from './texts.js' 7 | 8 | export async function make_meme ( 9 | e, 10 | memekey, 11 | min_texts, 12 | max_texts, 13 | min_images, 14 | max_images, 15 | options, 16 | userText, 17 | isPreset, 18 | PresetKeyWord 19 | ) { 20 | try { 21 | const getquotedUser = async (e)=> { 22 | let source = null 23 | if (e.reply_id) { 24 | source = await e.getReply() 25 | } else if (e.source) { 26 | if (e.isGroup) { 27 | source = await Bot[e.self_id].pickGroup(e.group_id).getChatHistory(e.reply_id ?? e.source.seq, 1) 28 | } else if (e.isPrivate) { 29 | source = await Bot[e.self_id].pickFriend(e.user_id).getChatHistory((Math.floor(Date.now() / 1000)), 1) 30 | } 31 | } 32 | if (source) { 33 | const sourceArray = Array.isArray(source) ? source : [ source ] 34 | return sourceArray[0].sender.user_id.toString() 35 | } 36 | return null 37 | } 38 | 39 | const quotedUser = await getquotedUser(e) 40 | const allUsers = [ 41 | ...new Set([ 42 | ...e.message 43 | .filter(m => m?.type === 'at') 44 | .map(at => at?.qq?.toString() ?? ''), 45 | ...[ ...(userText?.matchAll(/@\s*(\d+)/g) ?? []) ].map(match => match[1] ?? '') 46 | ]) 47 | ].filter(id => id && id !== quotedUser) 48 | 49 | let formdata = { 50 | images: [], 51 | texts: [], 52 | options: {} 53 | } 54 | 55 | if (options) { 56 | const option = await handleOption(e, memekey, userText, formdata, isPreset, PresetKeyWord) 57 | if (!option.success) { 58 | throw new Error(option.message) 59 | } 60 | userText = option.text 61 | } 62 | 63 | if (min_texts > 0 && max_texts > 0) { 64 | const text = await handleTexts(e, memekey, min_texts, max_texts, userText, formdata) 65 | if (!text.success) { 66 | throw new Error(text.message) 67 | } 68 | } 69 | 70 | if (min_images > 0 && max_images > 0) { 71 | const image = await handleImages(e, memekey, min_images, max_images, allUsers, quotedUser, userText, formdata) 72 | if (!image.success) { 73 | throw new Error(image.message) 74 | } 75 | } 76 | const response = await utils.make_meme(memekey, formdata) 77 | const basedata = await utils.getImageBase64(response) 78 | if (Config.stat.enable && e.isGroup) { 79 | const groupStart = (await db.stat.get({ 80 | groupId: e.group_id, 81 | memeKey: memekey 82 | }))?.count ?? 0 83 | await db.stat.add({ 84 | groupId: e.group_id, 85 | memeKey: memekey, 86 | count: Number(groupStart) + 1 87 | }) 88 | } 89 | return `base64://${basedata}` 90 | } catch (error) { 91 | logger.error(error) 92 | throw new Error(error.message) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /models/make/options.js: -------------------------------------------------------------------------------- 1 | import { utils } from '#models' 2 | export async function handleOption ( 3 | e, 4 | memekey, 5 | userText, 6 | formdata, 7 | isPreset, 8 | PresetKeyWord 9 | ) { 10 | let options = {} 11 | const optionsMatches = userText.match(/#(\S+)\s+([^#]+)/g) 12 | const optionArray = [] 13 | 14 | if (isPreset) { 15 | const presetInfo = await utils.get_preset_info_by_keyword(PresetKeyWord) 16 | if (!presetInfo) { 17 | return { 18 | success: false, 19 | message: '获取预设信息失败' 20 | } 21 | } 22 | optionArray.push({ 23 | name: presetInfo.option_name, 24 | value: presetInfo.option_value 25 | }) 26 | } 27 | if (optionsMatches) { 28 | for (const match of optionsMatches) { 29 | const [ , name, value ] = match.match(/#(\S+)\s+([^#]+)/) ?? null 30 | if (name && value) { 31 | optionArray.push({ 32 | name: name.trim(), 33 | value: value.trim() 34 | }) 35 | } 36 | } 37 | } 38 | 39 | const optionsInfo = (await utils.get_meme_info(memekey))?.options ?? null 40 | if (!optionsInfo) { 41 | return { 42 | success: false, 43 | message: '获取选项信息失败' 44 | } 45 | } 46 | 47 | const optionsArray = Array.isArray(optionsInfo) 48 | ? optionsInfo 49 | : JSON.parse(optionsInfo) 50 | 51 | for (const option of optionArray) { 52 | const supportedOption = optionsArray.find( 53 | (opt) => opt.name === option.name 54 | ) 55 | if (!supportedOption) { 56 | return { 57 | success: false, 58 | message: `该表情不支持参数:${option.name}` 59 | } 60 | } 61 | 62 | const result = convertOptionValue(option, supportedOption) 63 | if (!result.success) { 64 | return { 65 | success: false, 66 | message: result.message 67 | } 68 | } 69 | 70 | options[option.name] = result.value 71 | } 72 | 73 | formdata['options'] = options 74 | return { 75 | success: true, 76 | text: userText.replace(/#(\S+)\s+([^#]+)/g, '').trim() 77 | } 78 | } 79 | 80 | /** 81 | * 转换选项值为指定类型 82 | * 83 | * @param option - 选项对象 84 | * @param option.name - 选项名称 85 | * @param option.value - 选项值(字符串形式) 86 | * @param supportedOption - 支持的选项类型定义 87 | * @returns 转换结果对象 88 | */ 89 | function convertOptionValue (option, supportedOption) { 90 | let convertedValue 91 | 92 | switch (supportedOption.type) { 93 | case 'boolean': { 94 | const boolValue = option.value.toLowerCase() 95 | if ([ 'true', '真', '是', 'yes', '1' ].includes(boolValue)) { 96 | convertedValue = true 97 | } else if ([ 'false', '假', '否', 'no', '0' ].includes(boolValue)) { 98 | convertedValue = false 99 | } else { 100 | return { 101 | success: false, 102 | message: `参数 ${option.name} 需要是布尔值` 103 | } 104 | } 105 | return { success: true, value: convertedValue } 106 | } 107 | 108 | case 'integer': { 109 | const intValue = parseInt(option.value) 110 | if (isNaN(intValue)) { 111 | return { 112 | success: false, 113 | message: `参数 ${option.name} 需要是整数` 114 | } 115 | } 116 | if ( 117 | supportedOption.minimum !== null && 118 | intValue < supportedOption.minimum 119 | ) { 120 | return { 121 | success: false, 122 | message: `参数 ${option.name} 不能小于 ${supportedOption.minimum}` 123 | } 124 | } 125 | if ( 126 | supportedOption.maximum !== null && 127 | intValue > supportedOption.maximum 128 | ) { 129 | return { 130 | success: false, 131 | message: `参数 ${option.name} 不能大于 ${supportedOption.maximum}` 132 | } 133 | } 134 | return { 135 | success: true, 136 | value: intValue 137 | } 138 | } 139 | 140 | case 'float': { 141 | const floatValue = parseFloat(option.value) 142 | if (isNaN(floatValue)) { 143 | return { 144 | success: false, 145 | message: `参数 ${option.name} 需要是数字` 146 | } 147 | } 148 | if ( 149 | supportedOption.minimum !== null && 150 | floatValue < supportedOption.minimum 151 | ) { 152 | return { 153 | success: false, 154 | message: `参数 ${option.name} 不能小于 ${supportedOption.minimum}` 155 | } 156 | } 157 | if ( 158 | supportedOption.maximum !== null && 159 | floatValue > supportedOption.maximum 160 | ) { 161 | return { 162 | success: false, 163 | message: `参数 ${option.name} 不能大于 ${supportedOption.maximum}` 164 | } 165 | } 166 | return { 167 | success: true, 168 | value: floatValue 169 | } 170 | } 171 | 172 | case 'string': 173 | convertedValue = option.value 174 | if ( 175 | supportedOption.choices && 176 | !supportedOption.choices.includes(convertedValue) 177 | ) { 178 | return { 179 | success: false, 180 | message: `参数 ${ 181 | option.name 182 | } 的值必须是: ${supportedOption.choices.join(', ')} 之一` 183 | } 184 | } 185 | return { 186 | success: true, 187 | value: convertedValue 188 | } 189 | 190 | default: 191 | return { 192 | success: false, 193 | message: `不支持的参数类型:${supportedOption.type}` 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /models/make/texts.js: -------------------------------------------------------------------------------- 1 | import { utils } from '#models' 2 | 3 | export async function handleTexts ( 4 | e, 5 | memekey, 6 | min_texts, 7 | max_texts, 8 | userText, 9 | formdata 10 | ) { 11 | const texts = [] 12 | 13 | /** 用户输入的文本 */ 14 | if (userText) { 15 | const splitTexts = userText.split('/').map((text) => text.trim()) 16 | for (const text of splitTexts) { 17 | if (text) { 18 | texts.push(text) 19 | } 20 | } 21 | } 22 | 23 | const memeInfo = await utils.get_meme_info(memekey) 24 | const default_texts = memeInfo?.default_texts ? JSON.parse(String(memeInfo.default_texts)) : [] 25 | if ( 26 | texts.length < min_texts && 27 | default_texts 28 | ) { 29 | while (texts.length < min_texts) { 30 | const randomIndex = Math.floor(Math.random() * default_texts.length) 31 | texts.push(default_texts[randomIndex]) 32 | } 33 | } 34 | 35 | formdata['texts'] = texts 36 | 37 | return texts.length < min_texts 38 | ? { 39 | success: false, 40 | message: min_texts === max_texts 41 | ? `该表情需要${min_texts}个文本` 42 | : `该表情至少需要 ${min_texts} ~ ${max_texts} 个文本` 43 | } 44 | : { 45 | success: true, 46 | texts: userText 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /models/server/index.js: -------------------------------------------------------------------------------- 1 | export * from './manger.js' 2 | export * from './utils.js' 3 | -------------------------------------------------------------------------------- /models/server/manger.js: -------------------------------------------------------------------------------- 1 | import { spawn } from 'node:child_process' 2 | import fs from 'node:fs/promises' 3 | import os from 'node:os' 4 | import path from 'node:path' 5 | 6 | import TOML from 'smol-toml' 7 | 8 | import { Config } from '#components' 9 | import { utils } from '#models' 10 | 11 | import { checkPort, get_meme_server_name, get_meme_server_path, kil_meme_server } from './utils.js' 12 | 13 | let serverProcess = null 14 | 15 | const config = { 16 | meme: { 17 | load_builtin_memes: true, 18 | load_external_memes: false, 19 | meme_disabled_list: [] 20 | }, 21 | resource: { 22 | resource_url: 'https://cdn.jsdelivr.net/gh/MemeCrafters/meme-generator-rs@', 23 | download_fonts: true 24 | }, 25 | font: { 26 | use_local_fonts: true, 27 | default_font_families: [ 'Noto Sans SC', 'Noto Color Emoji' ] 28 | }, 29 | encoder: { 30 | gif_max_frames: 200, 31 | gif_encode_speed: 1 32 | }, 33 | api: { 34 | baidu_trans_appid: '', 35 | baidu_trans_apikey: '' 36 | }, 37 | server: { 38 | host: '0.0.0.0', 39 | port: 2255 40 | } 41 | } 42 | 43 | /** 44 | * 启动表情服务端 45 | * @param port - 端口 46 | * @returns 启动结果 47 | */ 48 | 49 | export async function start (port = 2255) { 50 | try { 51 | const configDir = path.join(os.homedir(), '.meme_generator') 52 | const configPath = path.join(configDir, 'config.toml') 53 | if (!await utils.exists(configDir)) { 54 | await fs.mkdir(configDir) 55 | } 56 | if (!await utils.exists(configPath)) { 57 | const defaultConfig = TOML.stringify(config) 58 | await fs.writeFile(configPath, defaultConfig) 59 | } 60 | const configContentBuffer = await fs.readFile(configPath) 61 | const configContent = configContentBuffer?.toString().trim() || TOML.stringify(config) 62 | const configData = TOML.parse(configContent) 63 | const download_url = 'https://cdn.jsdelivr.net/gh/MemeCrafters/meme-generator-rs@' 64 | const base_url = await utils.isAbroad() ? download_url : 'https://cdn.mengze.vip/gh/MemeCrafters/meme-generator-rs@' 65 | const url = Config.server.download_url?.trim() ? Config.server.download_url.replace(/\/+$/, '') : base_url 66 | configData.server.port = port 67 | configData.resource.resource_url = url 68 | const newConfigData = TOML.stringify(configData) 69 | await fs.writeFile(configPath, newConfigData) 70 | const memeServerPath = path.join(`${get_meme_server_path()}/${get_meme_server_name()}`) 71 | if (!memeServerPath) { 72 | throw new Error('未找到表情服务端文件') 73 | } 74 | const available = await checkPort(port) 75 | if (available) { 76 | if (Config.server.kill) { 77 | await kil_meme_server(port) 78 | } else { 79 | throw new Error(`[meme-server] 端口${port}已被占用, 请检查并稍后重启`) 80 | } 81 | } 82 | serverProcess = spawn(memeServerPath, [ 'run' ], { stdio: 'inherit' }) 83 | serverProcess.on('error', (error) => { 84 | logger.error(error) 85 | throw new Error(`启动服务器失败: ${(error).message}`) 86 | }) 87 | } catch (error) { 88 | logger.error(error) 89 | throw new Error(`启动服务器失败: ${error.message}`) 90 | } 91 | } 92 | 93 | /** 94 | * 停止表情服务端 95 | * @returns 停止结果 96 | */ 97 | export async function stop () { 98 | try { 99 | if (serverProcess) { 100 | await new Promise((resolve, reject) => { 101 | serverProcess.on('exit', resolve) 102 | serverProcess.on('error', reject) 103 | serverProcess.kill() 104 | }) 105 | serverProcess = null 106 | } else { 107 | throw new Error('表情服务端未运行') 108 | } 109 | } catch (error) { 110 | logger.error(error) 111 | throw new Error(`停止服务器失败: ${error.message}`) 112 | } 113 | } 114 | 115 | /** 116 | * 重启表情服务端 117 | * @returns 重启结果 118 | */ 119 | export async function restart () { 120 | try { 121 | if (serverProcess) { 122 | await stop() 123 | } 124 | await start() 125 | } catch (error) { 126 | logger.error(error) 127 | throw new Error(`重启服务器失败: ${error.message}`) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /models/server/utils.js: -------------------------------------------------------------------------------- 1 | import { exec as childExec, spawn } from 'node:child_process' 2 | import fs from 'node:fs/promises' 3 | import net from 'node:net' 4 | import os from 'node:os' 5 | import path from 'node:path' 6 | import { promisify } from 'node:util' 7 | 8 | import AdmZip from 'adm-zip' 9 | 10 | import { Config, Version } from '#components' 11 | import { utils } from '#models' 12 | 13 | import { start } from './manger.js' 14 | 15 | const exec = promisify(childExec) 16 | /** 17 | * 格式化日期时间 18 | */ 19 | function formatRuntime (diffMs) { 20 | const seconds = Math.floor(diffMs / 1000) 21 | const minutes = Math.floor(seconds / 60) 22 | const hours = Math.floor(minutes / 60) 23 | const days = Math.floor(hours / 24) 24 | 25 | const remainingHours = hours % 24 26 | const remainingMinutes = minutes % 60 27 | const remainingSeconds = seconds % 60 28 | 29 | let runtime = '' 30 | if (days > 0) runtime += `${days}天` 31 | if (remainingHours > 0) runtime += `${remainingHours}小时` 32 | if (remainingMinutes > 0) runtime += `${remainingMinutes}分钟` 33 | runtime += `${remainingSeconds}秒` 34 | 35 | return runtime 36 | } 37 | 38 | /* 39 | * 获取本地IP地址 40 | * @returns 本地IP地址 41 | */ 42 | export async function get_local_ip () { 43 | const interfaces = os.networkInterfaces() 44 | 45 | for (const devName in interfaces) { 46 | const iface = interfaces[devName] 47 | if (!iface) continue 48 | 49 | for (const alias of iface) { 50 | if (!alias) continue 51 | 52 | if ( 53 | alias.family === 'IPv4' && 54 | alias.address !== '127.0.0.1' && 55 | !alias.internal 56 | ) { 57 | return alias.address 58 | } 59 | } 60 | } 61 | 62 | return Promise.resolve('127.0.0.1') 63 | } 64 | 65 | 66 | /** 67 | * 重启表情服务端 68 | * @returns 重启结果 69 | */ 70 | 71 | const save_path = path.join(Version.Plugin_Path, 'data', 'server') 72 | /** 73 | * 下载表情服务端 74 | * 自动根据当前系统类型下载对应的表情服务端并保存到本地 75 | */ 76 | export async function download_server () { 77 | try { 78 | let file_name 79 | if (!await utils.exists(save_path)) { 80 | await fs.mkdir(save_path) 81 | } 82 | const type = os.type() 83 | const arch = os.arch() 84 | switch (type) { 85 | case 'Windows_NT': 86 | file_name = 'meme-generator-cli-windows-x86_64.zip' 87 | break 88 | case 'Linux': 89 | switch (arch) { 90 | case 'x64': 91 | file_name = 'meme-generator-cli-linux-x86_64.zip' 92 | break 93 | case 'arm64': 94 | file_name = 'meme-generator-cli-linux-aarch64.zip' 95 | break 96 | default: 97 | throw new Error('不支持的架构') 98 | } 99 | break 100 | case 'Darwin': 101 | switch (arch) { 102 | case 'x64': 103 | file_name = 'meme-generator-cli-macos-x86_64.zip' 104 | break 105 | case 'arm64': 106 | file_name = 'meme-generator-cli-macos-aarch64.zip' 107 | break 108 | default: 109 | throw new Error('不支持的架构') 110 | } 111 | break 112 | default: 113 | throw new Error('不支持的操作系统') 114 | } 115 | const github_url = 'https://github.com' 116 | const base_url = await utils.isAbroad() ? github_url : `https://github.moeyy.xyz/${github_url}` 117 | const url = Config.server.proxy_url?.trim() ? `${Config.server.proxy_url.replace(/\/+$/, '')}/${github_url}` : base_url 118 | const release_url = `${url}/MemeCrafters/meme-generator-rs/releases/latest/download/${file_name}` 119 | const res = await utils.Request.get(release_url, null, null, 'arraybuffer') 120 | if (!res.success) { 121 | throw new Error('下载表情服务端文件失败') 122 | } else if (res.status) { 123 | logger.info('下载表情服务端文件成功') 124 | } 125 | logger.debug('下载表情服务端文件成功') 126 | logger.debug('开始解压表情服务端文件') 127 | const zip = new AdmZip(res.data) 128 | zip.extractAllTo(save_path, true) 129 | logger.debug('解压表情服务端文件成功') 130 | } catch (error) { 131 | logger.error(error) 132 | throw new Error('下载表情服务端文件失败:' + error.message) 133 | } 134 | } 135 | 136 | /** 137 | * 下载表情服务端资源 138 | * @returns 更新结果 139 | */ 140 | export async function download_server_resource () { 141 | try { 142 | const server_path = path.join(`${get_meme_server_path()}/${get_meme_server_name()}`) 143 | if (!server_path) throw new Error('表情服务端文件不存在') 144 | 145 | return new Promise((resolve, reject) => { 146 | const downloadProcess = spawn(server_path, [ 'download' ], { stdio: 'inherit' }) 147 | 148 | downloadProcess.on('error', (error) => { 149 | logger.error(error) 150 | reject(new Error('下载表情服务端资源失败')) 151 | }) 152 | 153 | downloadProcess.on('close', (code) => { 154 | if (code !== 0) { 155 | reject(new Error('下载表情服务端资源失败')) 156 | } else { 157 | logger.info('下载表情服务端资源成功') 158 | resolve(true) 159 | } 160 | }) 161 | }) 162 | } catch (error) { 163 | logger.error(error) 164 | throw new Error('更新表情服务端资源失败: ' + error.message) 165 | } 166 | } 167 | 168 | /** 169 | * 获取表情服务端的名称 170 | * @returns 表情服务端的名称 171 | */ 172 | export function get_meme_server_name () { 173 | let name 174 | try { 175 | const type = os.type() 176 | switch (type) { 177 | case 'Windows_NT': 178 | name = 'meme.exe' 179 | break 180 | case 'Linux': 181 | case 'Darwin': 182 | name = 'meme' 183 | break 184 | default: 185 | throw new Error('不支持的操作系统') 186 | } 187 | return name 188 | } catch (error) { 189 | logger.error(error) 190 | return null 191 | } 192 | } 193 | 194 | /** 195 | * 获取表情服务端的路径 196 | * @returns 表情服务端的路径 197 | */ 198 | export function get_meme_server_path () { 199 | return path.join(save_path) 200 | } 201 | 202 | /** 203 | * 获取表情服务端的版本 204 | * @returns 表情服务端的版本 205 | */ 206 | export async function get_meme_server_version () { 207 | try { 208 | const url = await utils.get_base_url() 209 | const res = await utils.Request.get(`${url}/meme/version`) 210 | return res.data 211 | } catch (error) { 212 | logger.error(error) 213 | return null 214 | } 215 | } 216 | 217 | /** 218 | * 获取表情服务端的进程ID 219 | * @returns 表情服务端的进程ID 220 | */ 221 | export async function get_meme_server_pid () { 222 | try { 223 | const type = os.type() 224 | let command 225 | let args 226 | 227 | switch (type) { 228 | case 'Windows_NT': 229 | { 230 | command = 'wmic' 231 | const serverPath = path.join(get_meme_server_path() ?? '', get_meme_server_name() ?? '') 232 | args = [ 'process', 'where', `"ExecutablePath='${serverPath.replace(/\\/g, '\\\\')}'"`, 'get', 'ProcessId', '/value' ] 233 | break 234 | } 235 | case 'Linux': 236 | case 'Darwin': 237 | { 238 | command = 'pgrep' 239 | const serverPath = path.join(get_meme_server_path() ?? '', get_meme_server_name() ?? '') 240 | args = [ '-f', serverPath ?? '' ] 241 | break 242 | } 243 | default: 244 | throw new Error('不支持的操作系统') 245 | } 246 | 247 | const { stdout, stderr } = await exec(`${command} ${args.join(' ')}`) 248 | if (stderr) throw new Error('获取表情服务端进程ID失败') 249 | let pid 250 | 251 | if (type === 'Windows_NT') { 252 | const pidMatch = stdout.match(/ProcessId=(\d+)/) 253 | if (!pidMatch) { 254 | throw new Error('无法获取进程ID') 255 | } 256 | pid = pidMatch[1] 257 | } else { 258 | pid = stdout.trim().split('\n')[0] 259 | } 260 | if (!pid) { 261 | throw new Error('无法获取进程ID') 262 | } 263 | return pid 264 | } catch (error) { 265 | logger.error(error) 266 | throw new Error('获取表情服务端进程ID失败:' + error.message) 267 | } 268 | } 269 | 270 | /** 271 | * 获取表情服务端的运行时间 272 | * @returns 表情服务端的运行时间 273 | */ 274 | export async function get_meme_server_runtime () { 275 | try { 276 | const pid = await get_meme_server_pid() 277 | 278 | const type = os.type() 279 | let command 280 | let args 281 | 282 | switch (type) { 283 | case 'Windows_NT': 284 | command = 'wmic' 285 | args = [ 'process', 'where', `processid=${pid}`, 'get', 'CreationDate', '/value' ] 286 | break 287 | case 'Linux': 288 | case 'Darwin': 289 | command = 'ps' 290 | args = [ '-p', pid, '-o', 'etime=' ] 291 | break 292 | default: 293 | throw new Error('不支持的操作系统') 294 | } 295 | 296 | const { stdout, stderr } = await exec(`${command} ${args.join(' ')}`) 297 | if (stderr) throw new Error('获取表情服务端运行时间失败') 298 | let runtime = '' 299 | 300 | if (type === 'Windows_NT') { 301 | const match = stdout.match(/CreationDate=(\d+)/) 302 | if (match) { 303 | const wmicDate = match[1] 304 | const year = parseInt(wmicDate.substring(0, 4)) 305 | const month = parseInt(wmicDate.substring(4, 6)) - 1 306 | const day = parseInt(wmicDate.substring(6, 8)) 307 | const hours = parseInt(wmicDate.substring(8, 10)) 308 | const minutes = parseInt(wmicDate.substring(10, 12)) 309 | const seconds = parseInt(wmicDate.substring(12, 14)) 310 | const creationDate = new Date(year, month, day, hours, minutes, seconds) 311 | const now = new Date() 312 | const diffMs = now.getTime() - creationDate.getTime() 313 | runtime = formatRuntime(diffMs) 314 | } else { 315 | throw new Error('无法解析WMIC输出的创建日期') 316 | } 317 | } else { 318 | const etime = stdout.trim() 319 | const etimeMatch = etime.match(/(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)/) 320 | if (etimeMatch) { 321 | const [ , days, possibleHours, minutes, seconds ] = etimeMatch 322 | let hours = 0 323 | if (possibleHours) { 324 | hours = parseInt(possibleHours) 325 | } 326 | const diffMs = ((parseInt(days || '0') * 24 * 60 * 60) + 327 | (hours * 60 * 60) + 328 | parseInt(minutes) * 60 + 329 | parseInt(seconds)) * 1000 330 | runtime = formatRuntime(diffMs) 331 | } 332 | if (!etimeMatch) { 333 | throw new Error('无法获取进程运行时间') 334 | } 335 | if (!etimeMatch) { 336 | throw new Error('无法获取进程运行时间') 337 | } 338 | } 339 | 340 | return runtime 341 | } catch (error) { 342 | logger.error(error) 343 | return '未知' 344 | } 345 | } 346 | 347 | export async function get_meme_server_memory () { 348 | try { 349 | const pid = await get_meme_server_pid() 350 | const type = os.type() 351 | let command 352 | let args 353 | 354 | switch (type) { 355 | case 'Windows_NT': 356 | command = 'wmic' 357 | args = [ 'process', 'where', `processid=${pid}`, 'get', 'WorkingSetSize', '/value' ] 358 | break 359 | case 'Linux': 360 | case 'Darwin': 361 | command = 'ps' 362 | args = [ '-p', pid, '-o', 'rss=' ] 363 | break 364 | default: 365 | throw new Error('不支持的操作系统') 366 | } 367 | 368 | const { stdout, stderr } = await exec(`${command} ${args.join(' ')}`, { log: true }) 369 | if (stderr) throw new Error('获取表情服务端内存使用失败') 370 | let memoryInMB 371 | 372 | if (type === 'Windows_NT') { 373 | const match = stdout.match(/WorkingSetSize=(\d+)/) 374 | if (match) { 375 | const memoryStr = match[1] 376 | memoryInMB = parseInt(memoryStr) / (1024 * 1024) 377 | } else { 378 | throw new Error('获取表情服务端内存使用失败') 379 | } 380 | } else { 381 | memoryInMB = parseInt(stdout.trim()) / 1024 382 | } 383 | 384 | return memoryInMB % 1 === 0 ? memoryInMB.toFixed(0) : memoryInMB.toFixed(2) 385 | } catch (error) { 386 | logger.error(error) 387 | return '未知' 388 | } 389 | } 390 | 391 | /** 392 | * 获取表情服务端的表情包总数 393 | * @returns 表情服务端的表情包总数 394 | */ 395 | export async function get_meme_server_meme_total () { 396 | try { 397 | const url = await utils.get_base_url() 398 | const res = await utils.Request.get(`${url}/meme/keys`) 399 | return res.data.length 400 | } catch (error) { 401 | logger.error(error) 402 | return '未知' 403 | } 404 | } 405 | 406 | /** 407 | * 检查指定的端口是否被占用 408 | * 后续将改成karin内置函数 409 | * @param port 监听端口 410 | * @returns 被占用则返回 true,否则返回 false 411 | */ 412 | export async function checkPort (port) { 413 | return new Promise(resolve => { 414 | const server = net.createServer() 415 | 416 | server.once('error', () => { 417 | server.close() 418 | resolve(false) 419 | }) 420 | 421 | server.once('listening', () => { 422 | server.close() 423 | resolve(true) 424 | }) 425 | 426 | server.listen(port, '127.0.0.1') 427 | }) 428 | } 429 | 430 | /** 431 | * 杀死表情服务端进程 432 | * @param port - 端口 433 | * @returns 杀死结果 434 | */ 435 | export async function kil_meme_server (port) { 436 | const isWin = os.type() === 'Windows_NT' 437 | const getPid = async (port) => { 438 | const command = isWin ? `netstat -ano | findstr :${port}` : `lsof -i:${port} | grep LISTEN | awk '{print $2}'` 439 | const { stdout } = await exec(command) 440 | if (!stdout) return null 441 | if (isWin) { 442 | const pid = stdout.toString().split(/\s+/).filter(Boolean).pop() 443 | return isNaN(Number(pid)) ? null : Number(pid) 444 | } 445 | 446 | return isNaN(Number(stdout)) ? null : Number(stdout) 447 | } 448 | const pid = await getPid(Number(port)) 449 | if (!pid) return false 450 | const command = isWin 451 | ? `taskkill /F /PID ${pid}` 452 | : `kill -9 ${pid}` 453 | const { stderr } = await exec(command) 454 | if (stderr) return false 455 | return true 456 | } 457 | 458 | /** 459 | * 初始化表情服务端 460 | * @param port - 端口 461 | * @returns 初始化结果 462 | */ 463 | export async function init_server (port = 2255) { 464 | try { 465 | const server_path = path.join(`${get_meme_server_path()}/${get_meme_server_name()}`) 466 | if (!await utils.exists(server_path)) await download_server() 467 | const type = os.type() 468 | if (type === 'Linux') { 469 | await exec('chmod +x ' + server_path) 470 | } 471 | const resource_path = path.join(os.homedir(), '.meme_generator', 'resources') 472 | if (!await utils.exists(resource_path)) { 473 | logger.info('表情服务端资源不存在,请稍后使用[#柠糖表情下载表情服务端资源]命令下载') 474 | } 475 | await start(port) 476 | } catch (error) { 477 | logger.error(error) 478 | throw new Error('初始化表情服务端失败:' + error.message) 479 | } 480 | } 481 | -------------------------------------------------------------------------------- /models/utils/common.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | import os from 'node:os' 3 | import path from 'node:path' 4 | 5 | import { Config, Version } from '#components' 6 | import { server } from '#models' 7 | 8 | import Request from './request.js' 9 | import { exists } from './tools.js' 10 | /** 11 | * 获取图片 Buffer 12 | * @param {string | Buffer} image - 图片地址或 Buffer 13 | * @returns {Promise} - 返回图片的 Buffer 数据 14 | * @throws {Error} - 如果图片地址为空或请求失败,则抛出异常 15 | */ 16 | export async function getImageBuffer (image) { 17 | try { 18 | if (!image) throw new Error('图片地址不能为空') 19 | 20 | if (Buffer.isBuffer(image)) { 21 | return image 22 | } 23 | 24 | const response = await Request.get(image, null, null, 'arraybuffer') 25 | if (!response.success) { 26 | throw new Error(response.msg) 27 | } 28 | return response.data 29 | } catch (error) { 30 | logger.error(`获取图片Buffer失败: ${error.message}`) 31 | } 32 | } 33 | 34 | /** 35 | * 获取图片 Base64 字符串 36 | * @param {string | Buffer} image - 图片的 URL、Buffer 或 Base64 字符串 37 | * @returns {Promise} - 返回 Base64 编码的图片字符串,可能包含 `base64://` 前缀 38 | * @throws {Error} - 如果图片地址为空或处理失败,则抛出异常 39 | */ 40 | export async function getImageBase64 (image) { 41 | try { 42 | if (!image) { 43 | logger.error('图片地址不能为空') 44 | } 45 | 46 | if (typeof image === 'string') return image 47 | 48 | if (Buffer.isBuffer(image)) { 49 | return image.toString('base64') 50 | } 51 | 52 | const buffer = await getImageBuffer(image) 53 | return buffer.toString('base64') 54 | } catch (error) { 55 | logger.error(`获取图片Base64失败: ${error.message}`) 56 | } 57 | } 58 | 59 | /** 60 | * 获取基础 URL 61 | * @returns {Promise} 基础 URL 62 | */ 63 | export async function get_base_url () { 64 | try { 65 | let base_url 66 | if (!Config.server.url && !(Config.server.mode === 1)) 67 | throw new Error('请先使用未配置表情包API或使用本地服务') 68 | switch (Number(Config.server.mode)) { 69 | case 0: 70 | base_url = Config.server.url.replace(/\/+$/, '') 71 | break 72 | case 1: { 73 | const resources_path = path.join( 74 | os.homedir(), 75 | '.meme_generator', 76 | 'resources' 77 | ) 78 | if (!(await exists(resources_path))) { 79 | throw new Error('请先使用[#柠糖表情下载表情服务端资源]') 80 | } 81 | base_url = `http://${await server.get_local_ip()}:${ 82 | Config.server.port 83 | }` 84 | break 85 | } 86 | default: 87 | throw new Error('请检查服务器模式') 88 | } 89 | 90 | return Promise.resolve(base_url) 91 | } catch (error) { 92 | logger.error(error) 93 | throw new Error(error.message) 94 | } 95 | } 96 | 97 | /** 98 | * 异步判断是否在海外环境 99 | * @returns {Promise} 如果在海外环境返回 true,否则返回 false 100 | */ 101 | export async function isAbroad () { 102 | const urls = [ 103 | 'https://blog.cloudflare.com/cdn-cgi/trace', 104 | 'https://developers.cloudflare.com/cdn-cgi/trace', 105 | 'https://hostinger.com/cdn-cgi/trace', 106 | 'https://ahrefs.com/cdn-cgi/trace' 107 | ] 108 | 109 | try { 110 | const responses = await Promise.all( 111 | urls.map((url) => Request.get(url, null, null, 'text')) 112 | ) 113 | const traceTexts = responses.map((res) => res.data).filter(Boolean) 114 | const traceLines = traceTexts 115 | .flatMap((text) => text.split('\n').filter((line) => line)) 116 | .map((line) => line.split('=')) 117 | 118 | const traceMap = Object.fromEntries(traceLines) 119 | return traceMap.loc !== 'CN' 120 | } catch (error) { 121 | throw new Error(`获取 IP 所在地区出错: ${error.message}`) 122 | } 123 | } 124 | 125 | /** 126 | * 获取用户头像 127 | * @param {Message} e 消息事件 128 | * @param {string}userId 用户ID 129 | * @param {'url' | 'base64'}type 返回类型 url 或 base64 130 | * @returns {Promise<{userId:string,avatar:string} | null>}用户头像 131 | */ 132 | 133 | export async function get_user_avatar (e, userId, type = 'url') { 134 | try { 135 | if (!e) throw new Error('消息事件不能为空') 136 | if (!userId) throw new Error('用户ID不能为空') 137 | 138 | const getAvatarUrl = async (e, userId) => { 139 | let avatarUrl 140 | 141 | try { 142 | if (e.isGroup) { 143 | const member = e.bot.pickMember(e.group_id, userId) 144 | avatarUrl = await member.getAvatarUrl() 145 | } else if (e.isPrivate) { 146 | const friend = e.bot.pickFriend(userId) 147 | avatarUrl = await friend.getAvatarUrl() 148 | } 149 | } catch (err) {} 150 | if (!avatarUrl) { 151 | throw new Error(`获取用户头像地址失败: ${userId}`) 152 | } 153 | return avatarUrl 154 | } 155 | 156 | const avatarDir = path.join(Version.Plugin_Path, 'data', 'avatar') 157 | const cachePath = path.join(avatarDir, `${userId}.png`).replace(/\\/g, '/') 158 | 159 | if ( 160 | Config.meme.cache && 161 | Number(Config.server.mode) === 1 && 162 | (await exists(cachePath)) 163 | ) { 164 | const avatarUrl = await getAvatarUrl(e, userId) 165 | if (!avatarUrl) throw new Error(`获取用户头像失败: ${userId}`) 166 | const headRes = await Request.head(avatarUrl) 167 | const lastModified = headRes.data['last-modified'] 168 | const cacheStat = await fs.stat(cachePath) 169 | 170 | if (new Date(lastModified) <= cacheStat.mtime) { 171 | switch (type) { 172 | case 'base64': { 173 | const data = await fs.readFile(cachePath) 174 | if (!data) throw new Error(`通过缓存获取用户头像失败: ${userId}`) 175 | return { 176 | userId, 177 | avatar: data.toString('base64') 178 | } 179 | } 180 | case 'url': 181 | default: 182 | return { 183 | userId, 184 | avatar: cachePath 185 | } 186 | } 187 | } 188 | } 189 | 190 | const avatarUrl = await getAvatarUrl(e, userId) 191 | if (!avatarUrl) throw new Error(`获取用户头像失败: ${userId}`) 192 | 193 | if ( 194 | Config.meme.cache && 195 | Number(Config.server.mode) === 1 && 196 | !(await exists(avatarDir)) 197 | ) { 198 | await fs.mkdir(avatarDir) 199 | } 200 | 201 | const res = await Request.get(avatarUrl, null, null, 'arraybuffer') 202 | const avatarData = res.data 203 | 204 | if (Config.meme.cache && Number(Config.server.mode) === 1) { 205 | await fs.writeFile(cachePath, avatarData) 206 | } 207 | 208 | switch (type) { 209 | case 'base64': 210 | return { 211 | userId, 212 | avatar: avatarData.toString('base64') 213 | } 214 | case 'url': 215 | default: 216 | return { 217 | userId, 218 | avatar: 219 | Config.meme.cache && Number(Config.server.mode) === 1 220 | ? cachePath 221 | : avatarUrl 222 | } 223 | } 224 | } catch (error) { 225 | logger.error(error) 226 | return null 227 | } 228 | } 229 | 230 | /** 231 | * 获取用户昵称 232 | * @param {Message} e 消息事件 233 | * @param {string} userId 用户 ID 234 | * @returns 用户昵称 235 | */ 236 | export async function get_user_name (e, userId) { 237 | try { 238 | if (!e) throw new Error('消息事件不能为空') 239 | if (!userId) throw new Error('用户ID不能为空') 240 | userId = String(userId) 241 | let nickname 242 | let userInfo 243 | if (e.isGroup) { 244 | const memberInfo = Bot[e.self_id].pickMember(e.group_id, userId) 245 | userInfo = await memberInfo.getInfo() 246 | nickname = userInfo.card?.trim() || userInfo.nickname?.trim() || null 247 | } else if (e.isPrivate) { 248 | const friendInfo = Bot[e.self_id].pickFriend(userId) 249 | userInfo = await friendInfo.getInfo() 250 | nickname = userInfo.nickname.trim() ?? null 251 | } else { 252 | nickname = e.sender.nickname.trim() ?? null 253 | } 254 | if (!nickname) throw new Error('获取用户昵称失败') 255 | return nickname 256 | } catch (error) { 257 | logger.error(`获取用户昵称失败: ${error}`) 258 | return '未知' 259 | } 260 | } 261 | 262 | /** 263 | * 获取图片 264 | * @param {Message} e 消息事件 265 | * @param {'url' | 'base64'} type 返回类型 url 或 base64 266 | * @returns {Promise>} 图片数组信息 267 | */ 268 | export async function get_image (e, type = 'url') { 269 | if (!e) throw new Error('消息事件不能为空') 270 | const imagesInMessage = e.message 271 | .filter((m) => m.type === 'image') 272 | .map((img) => ({ 273 | userId: e.sender.user_id, 274 | url: img.url 275 | })) 276 | 277 | const replyId = e.reply_id ?? e.message.find((m) => m.type === 'reply')?.id 278 | 279 | const tasks = [] 280 | 281 | let quotedImages = [] 282 | let source = null 283 | if (e.getReply) { 284 | source = await e.getReply() 285 | } else if (replyId) { 286 | if (e.isGroup) { 287 | source = await Bot[e.self_id] 288 | .pickGroup(e.group_id) 289 | .getChatHistory(e.source.seq || replyId, 1) 290 | } else if (e.isPrivate) { 291 | source = await Bot[e.self_id] 292 | .pickFriend(e.user_id) 293 | .getChatHistory(Math.floor(Date.now() / 1000), 1) 294 | } 295 | } 296 | 297 | /** 298 | * 提取引用消息中的图片 299 | */ 300 | if (source) { 301 | const sourceArray = Array.isArray(source) ? source : [ source ] 302 | 303 | quotedImages = sourceArray.flatMap(({ message, sender }) => 304 | message 305 | .filter((element) => element.type === 'image') 306 | .map((element) => ({ 307 | userId: sender.user_id, 308 | url: element.url 309 | })) 310 | ) 311 | } 312 | 313 | /** 314 | * 处理引用消息中的图片 315 | */ 316 | if (quotedImages.length > 0) { 317 | for (const item of quotedImages) { 318 | switch (type) { 319 | case 'url': 320 | tasks.push( 321 | Promise.resolve({ 322 | userId: item.userId, 323 | image: item.url.toString() 324 | }) 325 | ) 326 | break 327 | case 'base64': 328 | default: 329 | tasks.push( 330 | Promise.resolve({ 331 | userId: item.userId, 332 | image: await getImageBase64(item.url) 333 | }) 334 | ) 335 | break 336 | } 337 | } 338 | } 339 | 340 | /** 341 | * 处理消息中的图片 342 | */ 343 | if (imagesInMessage.length > 0) { 344 | const imagePromises = imagesInMessage.map(async (item) => { 345 | switch (type) { 346 | case 'url': 347 | return { 348 | userId: item.userId, 349 | image: item.url.toString() 350 | } 351 | case 'base64': 352 | default: 353 | return { 354 | userId: item.userId, 355 | image: await getImageBase64(item.url) 356 | } 357 | } 358 | }) 359 | tasks.push(...imagePromises) 360 | } 361 | 362 | const results = await Promise.allSettled(tasks) 363 | const images = results 364 | .filter((res) => res.status === 'fulfilled' && Boolean(res.value)) 365 | .map((res) => res.value) 366 | 367 | return images 368 | } 369 | -------------------------------------------------------------------------------- /models/utils/index.js: -------------------------------------------------------------------------------- 1 | export * from './common.js' 2 | export * from './preset.js' 3 | export { default as Request } from './request.js' 4 | export * from './tools.js' -------------------------------------------------------------------------------- /models/utils/preset.js: -------------------------------------------------------------------------------- 1 | const preset = [ 2 | { 3 | name: '狠撅', 4 | key: 'do', 5 | option_name: 'fps', 6 | option_value: 50 7 | }, 8 | { 9 | name: '狠骑', 10 | key: 'qi', 11 | option_name: 'fps', 12 | option_value: 50 13 | }, 14 | /** ba说 */ 15 | { 16 | name: '心奈说', 17 | key: 'ba_say', 18 | option_name: 'character', 19 | option_value: 'kokona' 20 | }, 21 | { 22 | name: '爱丽丝说', 23 | key: 'ba_say', 24 | option_name: 'character', 25 | option_value: 'arisu' 26 | }, 27 | { 28 | name: '泉奈说', 29 | key: 'ba_say', 30 | option_name: 'character', 31 | option_value: 'izuna' 32 | }, 33 | { 34 | name: 'key说', 35 | key: 'ba_say', 36 | option_name: 'character', 37 | option_value: 'key' 38 | }, 39 | { 40 | name: '玛丽说', 41 | key: 'ba_say', 42 | option_name: 'character', 43 | option_value: 'mari' 44 | }, 45 | { 46 | name: '濑名说', 47 | key: 'ba_say', 48 | option_name: 'character', 49 | option_value: 'sena' 50 | }, 51 | { 52 | name: '优香说', 53 | key: 'ba_say', 54 | option_name: 'character', 55 | option_value: 'yuuka' 56 | }, 57 | /** 原神吃 */ 58 | { 59 | name: '八重神子吃', 60 | key: 'genshin_eat', 61 | option_name: 'character', 62 | option_value: 'nilou' 63 | }, 64 | { 65 | name: '神子吃', 66 | key: 'genshin_eat', 67 | option_name: 'character', 68 | option_value: 'nilou' 69 | }, 70 | { 71 | name: '八重吃', 72 | key: 'genshin_eat', 73 | option_name: 'character', 74 | option_value: 'nilou' 75 | }, 76 | { 77 | name: '胡桃吃', 78 | key: 'genshin_eat', 79 | option_name: 'character', 80 | option_value: 'hutao' 81 | }, 82 | { 83 | name: '妮露吃', 84 | key: 'genshin_eat', 85 | option_name: 'character', 86 | option_value: 'nilou' 87 | }, 88 | { 89 | name: '可莉吃', 90 | key: 'genshin_eat', 91 | option_name: 'character', 92 | option_value: 'klee' 93 | }, 94 | { 95 | name: '刻晴吃', 96 | key: 'genshin_eat', 97 | option_name: 'character', 98 | option_value: 'keqing' 99 | }, 100 | { 101 | name: '钟离吃', 102 | key: 'genshin_eat', 103 | option_name: 'character', 104 | option_value: 'zhongli' 105 | }, 106 | /** 世界计划 */ 107 | { 108 | name: '爱莉说', 109 | key: 'pjsk', 110 | option_name: 'character', 111 | option_value: 'airi' 112 | }, 113 | { 114 | name: '彰人说', 115 | key: 'pjsk', 116 | option_name: 'character', 117 | option_value: 'akito' 118 | }, 119 | { 120 | name: '杏说', 121 | key: 'pjsk', 122 | option_name: 'character', 123 | option_value: 'an' 124 | }, 125 | { 126 | name: '梦说', 127 | key: 'pjsk', 128 | option_name: 'character', 129 | option_value: 'emu' 130 | }, 131 | { 132 | name: '绘名说', 133 | key: 'pjsk', 134 | option_name: 'character', 135 | option_value: 'ena' 136 | }, 137 | { 138 | name: '遥说', 139 | key: 'pjsk', 140 | option_name: 'character', 141 | option_value: 'haruka' 142 | }, 143 | { 144 | name: '穗波说', 145 | key: 'pjsk', 146 | option_name: 'character', 147 | option_value: 'honami' 148 | }, 149 | { 150 | name: '一歌说', 151 | key: 'pjsk', 152 | option_name: 'character', 153 | option_value: 'ichika' 154 | }, 155 | { 156 | name: 'KAITO说', 157 | key: 'pjsk', 158 | option_name: 'character', 159 | option_value: 'kaito' 160 | }, 161 | { 162 | name: '奏说', 163 | key: 'pjsk', 164 | option_name: 'character', 165 | option_value: 'kanade' 166 | }, 167 | { 168 | name: '心羽说', 169 | key: 'pjsk', 170 | option_name: 'character', 171 | option_value: 'kohane' 172 | }, 173 | { 174 | name: '连说', 175 | key: 'pjsk', 176 | option_name: 'character', 177 | option_value: 'len' 178 | }, 179 | { 180 | name: '流歌说', 181 | key: 'pjsk', 182 | option_name: 'character', 183 | option_value: 'luka' 184 | }, 185 | { 186 | name: '真冬说', 187 | key: 'pjsk', 188 | option_name: 'character', 189 | option_value: 'mafuyu' 190 | }, 191 | { 192 | name: 'MEIKO说', 193 | key: 'pjsk', 194 | option_name: 'character', 195 | option_value: 'meiko' 196 | }, 197 | { 198 | name: '初音未来说', 199 | key: 'pjsk', 200 | option_name: 'character', 201 | option_value: 'miku' 202 | }, 203 | { 204 | name: '实乃理说', 205 | key: 'pjsk', 206 | option_name: 'character', 207 | option_value: 'minori' 208 | }, 209 | { 210 | name: '瑞希说', 211 | key: 'pjsk', 212 | option_name: 'character', 213 | option_value: 'mizuki' 214 | }, 215 | { 216 | name: '宁宁说', 217 | key: 'pjsk', 218 | option_name: 'character', 219 | option_value: 'nene' 220 | }, 221 | { 222 | name: '铃说', 223 | key: 'pjsk', 224 | option_name: 'character', 225 | option_value: 'rin' 226 | }, 227 | { 228 | name: '类说', 229 | key: 'pjsk', 230 | option_name: 'character', 231 | option_value: 'rui' 232 | }, 233 | { 234 | name: '咲希说', 235 | key: 'pjsk', 236 | option_name: 'character', 237 | option_value: 'saki' 238 | }, 239 | { 240 | name: '志步说', 241 | key: 'pjsk', 242 | option_name: 'character', 243 | option_value: 'shiho' 244 | }, 245 | { 246 | name: '雫说', 247 | key: 'pjsk', 248 | option_name: 'character', 249 | option_value: 'shizuku' 250 | }, 251 | { 252 | name: '冬弥说', 253 | key: 'pjsk', 254 | option_name: 'character', 255 | option_value: 'touya' 256 | }, 257 | { 258 | name: '司说', 259 | key: 'pjsk', 260 | option_name: 'character', 261 | option_value: 'tsukasa' 262 | } 263 | ] 264 | export { preset } -------------------------------------------------------------------------------- /models/utils/request.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import axiosRetry from 'axios-retry' 3 | 4 | import { Config, Version } from '#components' 5 | 6 | class Request { 7 | axiosInstance 8 | 9 | constructor () { 10 | this.axiosInstance = axios.create({ 11 | timeout: Config.server.timeout * 1000, 12 | headers: { 13 | 'User-Agent': `${Version.Plugin_Name}/v${Version.Plugin_Version}` 14 | } 15 | }) 16 | 17 | // 配置重试机制 18 | axiosRetry(this.axiosInstance, { 19 | retries: Config.server.retry, 20 | retryDelay: () => 0, 21 | shouldResetTimeout: true, 22 | retryCondition: (error) => { 23 | if (error.response) { 24 | return error.response.status === 500 25 | } 26 | return axiosRetry.isNetworkOrIdempotentRequestError(error) 27 | } 28 | }) 29 | } 30 | 31 | /** 32 | * 发送请求 33 | * @param {'get' | 'post' | 'head'} method 请求方法 get post head 34 | * @param {string} url 请求地址 35 | * @param {any} data 请求数据 36 | * @param {Record | null} params 请求参数 37 | * @param {Record | null} headers 请求头 38 | * @param {'json' | 'arraybuffer'} responseType 响应类型 39 | * @returns 响应数据 40 | */ 41 | async request ( 42 | method, 43 | url, 44 | data, 45 | params, 46 | headers, 47 | responseType = 'json' 48 | ) { 49 | const config = { 50 | params, 51 | headers, 52 | responseType 53 | } 54 | 55 | try { 56 | let response 57 | switch (method.toLowerCase()) { 58 | case 'get': 59 | response = await this.axiosInstance.get(url, config) 60 | break 61 | case 'post': 62 | response = await this.axiosInstance.post(url, data, config) 63 | break 64 | case 'head': 65 | response = await this.axiosInstance.head(url, config) 66 | return { 67 | success: response.status >= 200 && response.status < 500, 68 | statusCode: response.status, 69 | data: Object.fromEntries( 70 | Object.entries(response.headers).map(([ k, v ]) => [ k.toLowerCase(), v ]) 71 | ), 72 | msg: response.status >= 200 && response.status < 500 ? '请求成功' : '请求失败' 73 | } 74 | default: 75 | throw new Error('暂不支持该请求方法') 76 | } 77 | return { 78 | success: response.status >= 200 && response.status < 500, 79 | statusCode: response.status, 80 | data: response.data, 81 | msg: response.status >= 200 && response.status < 500 ? '请求成功' : '请求失败' 82 | } 83 | } catch (error) { 84 | logger.error(error) 85 | const axiosError = error 86 | const errorMessage = this.handleError(axiosError) 87 | return { 88 | success: false, 89 | statusCode: axiosError.response?.status ?? 500, 90 | data: null, 91 | msg: errorMessage 92 | } 93 | } 94 | } 95 | 96 | /** 97 | * 发送 GET 请求 98 | * @param {string} url 请求地址 99 | * @param {Record | null} params 请求参数 100 | * @param {Record | null} headers 请求头 101 | * @param {'json' | 'arraybuffer'}responseType 响应类型 102 | * @returns 响应数据 103 | */ 104 | async get ( 105 | url, 106 | params, 107 | headers, 108 | responseType = 'json' 109 | ) { 110 | return this.request('get', url, null, params, headers, responseType) 111 | } 112 | 113 | /** 114 | * 发送 HEAD 请求 115 | * @param {string} url 请求地址 116 | * @param {Record | null} params 请求参数 117 | * @param {Record | null} headers 请求头 118 | * @param {'json' | 'arraybuffer'}responseType 响应类型 119 | * @returns 响应数据 120 | */ 121 | async head ( 122 | url, 123 | params, 124 | headers, 125 | responseType = 'json' 126 | ) { 127 | return this.request('head', url, null, params, headers, responseType) 128 | } 129 | 130 | /** 131 | * 发送 POST 请求 132 | * @param {string} url 请求地址 133 | * @param {any} data 请求数据 134 | * @param {Record | null} headers 请求头 135 | * @param {'json' | 'arraybuffer'} responseType 响应类型 136 | * @returns 响应数据 137 | */ 138 | async post ( 139 | url, 140 | data, 141 | headers, 142 | responseType = 'json' 143 | ) { 144 | return this.request('post', url, data, null, headers, responseType) 145 | } 146 | 147 | /** 148 | * 处理错误 149 | * @param error 错误对象 150 | * @returns 错误信息 151 | */ 152 | handleError (error) { 153 | if (axios.isAxiosError(error)) { 154 | let errorMessage 155 | 156 | if (error.code === 'ECONNABORTED') { 157 | errorMessage = '请求超时,请检查网络连接' 158 | } else if (error.code === 'ERR_NETWORK') { 159 | errorMessage = '网络连接异常, 请检查网络连接' 160 | } else if (error.response?.data) { 161 | if (Buffer.isBuffer(error.response.data)) { 162 | errorMessage = error.response.data.toString() 163 | } else if (typeof error.response.data === 'string') { 164 | errorMessage = error.response.data 165 | } else { 166 | errorMessage = JSON.stringify(error.response.data) 167 | } 168 | } else if (error.response?.statusText) { 169 | errorMessage = error.response.statusText.toString() 170 | } else if (error.message) { 171 | errorMessage = error.message 172 | } else { 173 | errorMessage = '未知网络错误' 174 | } 175 | 176 | return errorMessage 177 | } else { 178 | return error?.message ?? error 179 | } 180 | } 181 | } 182 | 183 | export default new Request() -------------------------------------------------------------------------------- /models/utils/tools.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | 3 | import { db, imageTool } from '#models' 4 | 5 | import { get_base_url } from './common.js' 6 | import { preset } from './preset.js' 7 | import Request from './request.js' 8 | 9 | /** 10 | * 异步判断文件是否存在 11 | * @param {string} path 12 | * @returns {Promise} 是否存在,true存在,false不存在 13 | */ 14 | export async function exists (path) { 15 | try { 16 | await fs.access(path) 17 | return true 18 | } catch (e) { 19 | return false 20 | } 21 | } 22 | 23 | 24 | /** 初始化数据 */ 25 | export async function init () { 26 | await update_meme() 27 | await update_preset() 28 | } 29 | 30 | /** 31 | * 更新表情数据 32 | * @param {boolean} force 是否强制更新 33 | * @returns 初始化结果 34 | */ 35 | export async function update_meme (force = false) { 36 | try { 37 | const keys = await get_meme_all_keys() 38 | if (keys && keys.length > 0 && !force) return 39 | const url = await get_base_url() 40 | const res = await Request.get(`${url}/meme/infos`) 41 | if (!res.success) throw new Error(res.msg) 42 | if (res.data && Array.isArray(res.data)) { 43 | await Promise.all(res.data.map(meme => { 44 | const { 45 | key, 46 | keywords: keyWords, 47 | params: { 48 | min_texts, 49 | max_texts, 50 | min_images, 51 | max_images, 52 | default_texts, 53 | options 54 | }, 55 | tags 56 | } = meme 57 | 58 | return add_meme({ 59 | key, 60 | keyWords: keyWords?.length ? keyWords : null, 61 | min_texts, 62 | max_texts, 63 | min_images, 64 | max_images, 65 | default_texts: default_texts?.length ? default_texts : null, 66 | options: options?.length ? options : null, 67 | tags: tags?.length ? tags : null 68 | }, { 69 | force 70 | }) 71 | })) 72 | } 73 | } catch (error) { 74 | logger.error(`初始化表情数据失败: ${error}`) 75 | } 76 | } 77 | 78 | /** 79 | * 更新预设数据 80 | * @param {boolean} force 是否强制更新 81 | * @returns 初始化结果 82 | */ 83 | export async function update_preset (force = false) { 84 | try { 85 | const keys = await get_preset_all_keys() 86 | if (keys && keys.length > 0 && !force) return 87 | await Promise.all( 88 | preset.map(async (preset) => { 89 | const memeExists = await db.meme.get(preset.key) 90 | if (!memeExists && !force) return 91 | await db.preset.add({ 92 | name: preset.name, 93 | key: preset.key, 94 | option_name: preset.option_name, 95 | option_value: preset.option_value 96 | }, { 97 | force 98 | }) 99 | }) 100 | ) 101 | } catch (error) { 102 | logger.error(`初始化预设数据失败: ${error}`) 103 | } 104 | } 105 | 106 | /** 107 | * 添加表情 108 | * @param data 表情数据 109 | * - key 表情的唯一标识符 110 | * - keyWords 表情的关键词列表 111 | * - min_texts 表情最少的文本数 112 | * - max_texts 表情最多的文本数 113 | * - min_images 表情最少的图片数 114 | * - max_images 表情最多的图片数 115 | * - default_texts 表情的默认文本列表 116 | * - options 表情的参数类型 117 | * - tags 表情的标签列表 118 | * @param force 是否强制更新 119 | * @returns 添加结果 120 | */ 121 | export async function add_meme ({ 122 | key, 123 | keyWords, 124 | min_texts, 125 | max_texts, 126 | min_images, 127 | max_images, 128 | default_texts, 129 | options, 130 | tags 131 | }, { 132 | force = false 133 | }) { 134 | const data = { 135 | key, 136 | keyWords, 137 | min_texts, 138 | max_texts, 139 | min_images, 140 | max_images, 141 | default_texts, 142 | options, 143 | tags 144 | } 145 | return await db.meme.add(data, { force }) 146 | } 147 | 148 | /** 149 | * 获取所有预设的键值信息 150 | * @returns {Promise} 键值信息列表 151 | */ 152 | export async function get_preset_all_keys () { 153 | const res = await db.preset.getAll() 154 | return res.map(preset => preset.key).flat() ?? null 155 | } 156 | 157 | /** 158 | * 获取所有预设表情的关键词信息 159 | * @returns {Promise} 关键词信息列表 160 | */ 161 | export async function get_preset_all_keywords () { 162 | const res = await db.preset.getAll() 163 | return res.map(preset => preset.name).flat() ?? null 164 | } 165 | 166 | /** 167 | * 通过关键词获取预设表情的键值 168 | * @param keyword 关键词 169 | * @returns 键值 170 | */ 171 | export async function get_preset_key (keyword) { 172 | const res = await get_preset_info_by_keyword(keyword) 173 | if (!res) return null 174 | return res.key 175 | } 176 | 177 | /** 178 | * 通过表情的键值获取预设表情的关键词 179 | * @param key 表情的唯一标识符 180 | * @returns 预设表情信息 181 | */ 182 | export async function get_preset_keyword (key) { 183 | const res = await db.preset.getAll() 184 | const filteredOptions = res 185 | .filter(preset => preset.key === key) 186 | .map(preset => preset.name) 187 | return filteredOptions.length > 0 ? filteredOptions : null 188 | } 189 | /** 190 | * 获取指定的预设表情信息 191 | * @param {string} key 表情的唯一标识符 192 | * @returns {Promise} 预设表情信息 193 | */ 194 | export async function get_preset_info (key) { 195 | return await db.preset.get(key) 196 | } 197 | 198 | /** 199 | * 通过关键词获取预设表情信息 200 | * @param keyword 表情关键词 201 | * @returns 预设表情信息 202 | */ 203 | export async function get_preset_info_by_keyword (keyword) { 204 | return await db.preset.getByKeyWord(keyword) 205 | } 206 | 207 | /** 208 | * 获取所有相关预设表情的键值 209 | * @param keyword 表情的关键词 210 | * @returns 所有相关预设表情的键值列表 211 | */ 212 | export async function get_preset_all_about_keywords (keyword) { 213 | const res = await db.preset.getByKeyWordAbout(keyword) 214 | return res.map(preset => preset.name).flat() ?? null 215 | } 216 | 217 | /** 218 | * 获取所有相关预设表情的关键词 219 | * @param key 表情的唯一标识符 220 | * @returns 所有相关预设表情的键值列表 221 | */ 222 | export async function get_preset_all_about_keywords_by_key (key) { 223 | const res = await db.preset.getAbout(key) 224 | return res.map(preset => preset.name).flat() ?? null 225 | } 226 | 227 | /** 228 | * 获取所有表情的键值信息 229 | * @returns 键值信息列表 230 | */ 231 | export async function get_meme_all_keys () { 232 | const res = await db.meme.getAll() 233 | return res.map(meme => meme.key).flat() ?? null 234 | } 235 | 236 | /** 237 | * 通过关键词获取表情键值 238 | * @param {string} keyword 表情关键词 239 | * @returns {Promise} 表情键值 240 | */ 241 | export async function get_meme_key_by_keyword (keyword) { 242 | const res = await get_meme_info_by_keyword(keyword) 243 | if (!res) return null 244 | return res.key 245 | } 246 | 247 | /** 248 | * 通过键值获取表情的标签信息 249 | * @param {string} tag 表情的tag 250 | * @returns 表情的标签信息 251 | */ 252 | export async function get_meme_key_by_tag (tag) { 253 | const res = await db.meme.getByTag(tag) 254 | if (!res) return null 255 | return JSON.parse(String(res.key)) 256 | } 257 | 258 | /** 259 | * 获取所有所有相关表情的键值 260 | * @param {string} key 表情的唯一标识符 261 | * @returns {Promise} 所有相关表情的键值列表 262 | */ 263 | export async function get_meme_keys_by_about (key) { 264 | const res = await db.meme.getKeysByAbout(key) 265 | return res.map(meme => meme.key).flat() ?? null 266 | } 267 | 268 | /** 269 | * 获取所有所有相关表情的键值 270 | * @param {string} tag 表情的标签 271 | * @returns 所有相关表情的键值列表 272 | */ 273 | export async function get_meme_keys_by_about_tag (tag) { 274 | const res = await db.meme.getTagsByAbout(tag) 275 | return res.map(meme => meme.key).flat() ?? null 276 | } 277 | 278 | /** 279 | * 获取所有表情的关键词信息 280 | * @returns {Promise } 关键词信息列表 281 | */ 282 | export async function get_meme_all_keywords () { 283 | const res = await db.meme.getAll() 284 | return res.map((item) => JSON.parse(String(item.keyWords))).flat() ?? null 285 | } 286 | 287 | /** 288 | * 通过键值获取表情的关键词信息 289 | * @param {string} key 表情的唯一标识符 290 | * @returns {Promise } 表情的关键词信息 291 | */ 292 | export async function get_meme_keyword (key) { 293 | const res = await get_meme_info(key) 294 | if (!res) return null 295 | return JSON.parse(String(res.keyWords)) 296 | } 297 | 298 | /** 299 | * 通过关键词获取表情的标签信息 300 | * @param {string} tag 表情的标签 301 | * @returns 表情的标签信息 302 | */ 303 | export async function get_meme_keyword_by_tag (tag) { 304 | const res = await db.meme.getByTag(tag) 305 | if (!res) return null 306 | return JSON.parse(String(res.keyWords)) 307 | } 308 | 309 | /** 310 | * 获取所有相关表情的关键词信息 311 | * @param {string} keyword 表情关键词 312 | * @returns {Promise} 所有相关表情的关键词列表 313 | */ 314 | export async function get_meme_keywords_by_about (keyword) { 315 | const res = await db.meme.getKeyWordsByAbout(keyword) 316 | return res.map((item) => JSON.parse(String(item.keyWords))).flat() ?? null 317 | } 318 | 319 | /** 320 | * 获取所有相关表情的关键词信息 321 | * @param {string} tag 表情的标签 322 | * @returns 所有相关表情的关键词列表 323 | */ 324 | export async function get_meme_keywords_by_about_tag (tag) { 325 | const res = await db.meme.getTagsByAbout(tag) 326 | return res.map((item) => JSON.parse(String(item.keyWords))).flat() ?? null 327 | } 328 | 329 | /** 330 | * 获取所有表情的标签信息 331 | * @returns 标签信息列表 332 | */ 333 | export async function get_meme_all_tags () { 334 | const res = await db.meme.getAll() 335 | return res.map((item) => JSON.parse(String(item.tags))).flat() ?? null 336 | } 337 | /** 338 | * 获取表情信息 339 | * @param {string} key 表情唯一标识符 340 | * @returns {Promise} 表情信息 341 | */ 342 | export async function get_meme_info (key) { 343 | return await db.meme.get(key) ?? null 344 | } 345 | 346 | /** 347 | * 通过关键词获取表情信息 348 | * @param {string} keyword 表情关键词 349 | * @returns {Promise } 表情信息 350 | */ 351 | export async function get_meme_info_by_keyword (keyword) { 352 | return await db.meme.getByKeyWord(keyword) ?? null 353 | } 354 | 355 | /** 356 | * 上传图片 357 | * @param {Buffer | string} image 图片数据 358 | * @param {'url' | 'path' | 'data'} type 上传的图片类型 359 | * - url 图片的网络地址 360 | * - path 图片的本地路径 361 | * - data 图片的base64数据 362 | * @param {Record} headers 请求头,仅在type为url时生效 363 | * @returns {Promise} image_id 图片的唯一标识符 364 | */ 365 | export async function upload_image ( 366 | image, 367 | type = 'url', 368 | headers 369 | ) { 370 | try { 371 | const url = await get_base_url() 372 | let data 373 | switch (type) { 374 | case 'url': 375 | data = { 376 | type: 'url', 377 | url: image, 378 | ...(headers && { headers }) 379 | } 380 | break 381 | case 'path': 382 | data = { 383 | type: 'path', 384 | path: image 385 | } 386 | break 387 | case 'data': 388 | data = { 389 | type: 'data', 390 | data: Buffer.isBuffer(image) ? image.toString('base64') : image 391 | } 392 | break 393 | } 394 | const res = await Request.post(`${url}/image/upload`, data, {}, 'json') 395 | if (!res.success) throw new Error('图片上传失败') 396 | return res.data.image_id 397 | } catch (error) { 398 | logger.error(error) 399 | throw new Error(error.message) 400 | } 401 | } 402 | /** 403 | * 获取表情预览地址 404 | * @param {string} key 表情唯一标识符 405 | * @returns {Promise} 表情数据 406 | */ 407 | export async function get_meme_preview (key) { 408 | try { 409 | const url = await get_base_url() 410 | const res = await Request.get(`${url}/memes/${key}/preview`) 411 | if (!res.success) throw new Error(res.msg) 412 | const image = await imageTool.get_image(res.data.image_id, 'base64') 413 | return image 414 | } catch (error) { 415 | logger.error(error) 416 | throw new Error(error.message) 417 | } 418 | } 419 | 420 | /** 421 | * 生成表情图片 422 | * @param {string} memekey 表情唯一标识符 423 | * @param {any} data 表情数据 424 | * @returns {Promise} 表情图片数据 425 | */ 426 | export async function make_meme (memekey, data) { 427 | try { 428 | const url = await get_base_url() 429 | const res = await Request.post(`${url}/memes/${memekey}`, data, null, 'json') 430 | if (!res.success) { 431 | throw new Error(res.msg) 432 | } 433 | const image = await imageTool.get_image(res.data.image_id) 434 | if (!image) throw new Error('获取图片失败') 435 | return image 436 | } catch (error) { 437 | logger.error(error) 438 | throw new Error(error.message) 439 | } 440 | } 441 | 442 | /** 443 | * 向指定的群或好友发送文件 444 | * @param type 发送的类型 445 | * - group 为群 446 | * - private 为好友 447 | * @param botId 机器人的id 448 | * @param id 群或好友的id 449 | * @param file 文件路径 450 | * @param name 文件名称 451 | * @returns 发送结果 452 | */ 453 | export async function send_file (type, botId, id, file, name) { 454 | try { 455 | const bot = Bot[botId] 456 | if (type === 'group') { 457 | await bot.pickGroup(id).fs.upload(file, '/', name) 458 | } else if (type === 'private') { 459 | await bot.pickFriend(id).sendMsg([ segment.file(file, name) ]) 460 | } else { 461 | throw new Error('type 必须为 group 或 private') 462 | } 463 | } catch (error) { 464 | throw new Error(`向${type === 'group' ? '群' : '好友'} ${id} 发送文件失败: ${error.message}`) 465 | } 466 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meme-plugin", 3 | "version": "2.0.1", 4 | "author": "CandriaJS", 5 | "type": "module", 6 | "dependencies": { 7 | "adm-zip": "^0.5.16", 8 | "axios": "^1.8.4", 9 | "axios-retry": "4.5.0", 10 | "markdown-it": "^14.1.0", 11 | "sequelize": "^6.37.7", 12 | "smol-toml": "^1.3.4", 13 | "sqlite3": "5.1.6" 14 | }, 15 | "devDependencies": { 16 | "eslint": "^9.25.0", 17 | "eslint-plugin-simple-import-sort": "^12.1.1", 18 | "globals": "^15.15.0", 19 | "neostandard": "^0.12.1" 20 | }, 21 | "scripts": { 22 | "lint": "eslint .", 23 | "lint:fix": "eslint . --fix" 24 | }, 25 | "imports": { 26 | "#components": "./components/index.js", 27 | "#models": "./models/index.js" 28 | }, 29 | "packageManager": "pnpm@9.13.2+sha512.88c9c3864450350e65a33587ab801acf946d7c814ed1134da4a924f6df5a2120fd36b46aab68f7cd1d413149112d53c7db3a4136624cfd00ff1846a0c6cef48a" 30 | } 31 | -------------------------------------------------------------------------------- /resources/admin/imgs/bg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CandriaJS/meme-plugin/9120261a4bf8057b77fbc911e955057fac8628df/resources/admin/imgs/bg.webp -------------------------------------------------------------------------------- /resources/admin/imgs/cfg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CandriaJS/meme-plugin/9120261a4bf8057b77fbc911e955057fac8628df/resources/admin/imgs/cfg.webp -------------------------------------------------------------------------------- /resources/admin/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | transform: scale(1); 3 | width: 660px; 4 | } 5 | .container { 6 | background: url("./imgs/bg.webp") #000144 left top repeat-y; 7 | background-size: 700px auto; 8 | width: 660px; 9 | } 10 | .head-box { 11 | margin: 0 0 80px 0; 12 | } 13 | .cfg-box { 14 | border-radius: 15px; 15 | margin-top: 20px; 16 | margin-bottom: 20px; 17 | padding: 5px 15px; 18 | overflow: hidden; 19 | background: #f5f5f5; 20 | box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.15); 21 | position: relative; 22 | background: rgba(35, 38, 57, 0.8); 23 | } 24 | .cfg-group { 25 | color: #ceb78b; 26 | font-size: 18px; 27 | font-weight: bold; 28 | padding: 10px 20px; 29 | } 30 | .cfg-li { 31 | border-radius: 18px; 32 | min-height: 36px; 33 | position: relative; 34 | overflow: hidden; 35 | margin-bottom: 10px; 36 | background: rgba(203, 196, 190, 0); 37 | } 38 | .cfg-line { 39 | color: #4e5769; 40 | line-height: 36px; 41 | padding-left: 20px; 42 | font-weight: bold; 43 | border-radius: 16px; 44 | box-shadow: 0 0 2px rgba(0, 0, 0, 0.5); 45 | background: url("./imgs/cfg.webp") right top #cbc4be no-repeat; 46 | background-size: auto 36px; 47 | } 48 | .cfg-hint { 49 | font-size: 12px; 50 | font-weight: normal; 51 | margin-top: 3px; 52 | margin-bottom: -3px; 53 | } 54 | .cfg-status { 55 | position: absolute; 56 | top: 0; 57 | right: 0; 58 | height: 36px; 59 | width: 160px; 60 | text-align: center; 61 | line-height: 36px; 62 | font-size: 16px; 63 | color: #495366; 64 | font-weight: bold; 65 | border-radius: 0 16px 16px 0; 66 | } 67 | .cfg-status.status-off { 68 | color: #a95151; 69 | } 70 | .cfg-desc { 71 | font-size: 12px; 72 | color: #cbc4be; 73 | margin: 5px 0 5px 20px; 74 | } 75 | /*# sourceMappingURL=index.css.map */ 76 | -------------------------------------------------------------------------------- /resources/admin/index.html: -------------------------------------------------------------------------------- 1 | {{extend defaultLayout}} 2 | {{block 'css'}} 3 | 4 | {{/block}} 5 | {{block 'main'}} 6 | 7 |
8 |
9 |
{{title}}管理面板
10 |
#{{title}}设置
11 |
12 |
13 | 14 | {{each schema cfgGroup cfgKey}} 15 |
16 |
{{cfgGroup.title}}
17 |
    18 | {{each cfgGroup.cfg cfgItem cfgItemKey}} 19 |
  • 20 |
    21 | 22 | #{{title}}设置 {{cfgGroup.title}} {{cfgItem.title}} 23 | {{if cfgItem.type === 'number'}} 数值范围:{{cfgItem.limit}} 24 | {{else if cfgItem.type === 'string'}} 请输入文本 25 | {{else if cfgItem.type === 'array'}} 添加/删除项目 26 | {{else}} 开启/关闭 27 | {{/if}} 28 | 29 |
    34 | {{if cfgItem.type === 'number'}} 35 | {{cfg[cfgKey][cfgItemKey] || '未设置'}} 36 | {{else if cfgItem.type === 'string'}} 37 | {{if cfg[cfgKey][cfgItemKey]}}已设置{{else}}未设置{{/if}} 38 | {{else if cfgItem.type === 'array'}} 39 | {{if cfg[cfgKey][cfgItemKey] && cfg[cfgKey][cfgItemKey].length > 0}} 40 | 已配置 {{cfg[cfgKey][cfgItemKey].length}} 项 41 | {{else}} 42 | 未配置 43 | {{/if}} 44 | {{else}} 45 | {{if cfg[cfgKey][cfgItemKey]}}已开启{{else}}已关闭{{/if}} 46 | {{/if}} 47 |
    48 |
    49 | {{if cfgItem.desc}} 50 |
    {{cfgItem.desc}}
    51 | {{/if}} 52 |
  • 53 | {{/each}} 54 |
55 |
56 | {{/each}} 57 | 58 | {{/block}} 59 | -------------------------------------------------------------------------------- /resources/code/imgs/bg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CandriaJS/meme-plugin/9120261a4bf8057b77fbc911e955057fac8628df/resources/code/imgs/bg.webp -------------------------------------------------------------------------------- /resources/code/index.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background-image: url('./imgs/bg.webp'); 3 | background-size: cover; 4 | background-repeat: no-repeat; 5 | background-position: center; 6 | } 7 | .info_box { 8 | padding: 20px; 9 | background: linear-gradient(135deg, rgba(50, 50, 70, 0.95), rgba(35, 38, 57, 0.95)); 10 | border-radius: 16px; 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | box-sizing: border-box; 15 | min-width: 90%; 16 | max-width: 100%; 17 | margin: 20px auto; 18 | border: 1px solid rgba(255, 255, 255, 0.15); 19 | font-family: 'Arial', sans-serif; 20 | } 21 | 22 | .label { 23 | font-size: 30px; 24 | font-weight: bold; 25 | color: #ffffff; 26 | text-align: center; 27 | margin-bottom: 16px; 28 | text-shadow: 0 3px 6px rgba(0, 0, 0, 0.5); 29 | letter-spacing: 1px; 30 | } 31 | 32 | .total { 33 | font-size: 20px; 34 | font-weight: 500; 35 | color: #ffffff; 36 | text-align: center; 37 | margin-bottom: 16px; 38 | text-shadow: 0 2px 4px rgba(0, 0, 0, 0.6); 39 | } 40 | 41 | .cfg-box { 42 | display: flex; 43 | flex-direction: column; 44 | gap: 12px; 45 | background: rgba(255, 255, 255, 0.1); 46 | border-radius: 12px; 47 | padding: 20px; 48 | width: 100%; 49 | box-sizing: border-box; 50 | border: 1px solid rgba(255, 255, 255, 0.2); 51 | } 52 | 53 | .cfg-ul { 54 | list-style: none; 55 | padding: 0; 56 | margin: 0; 57 | display: flex; 58 | flex-direction: column; 59 | gap: 16px; 60 | } 61 | 62 | .cfg-li { 63 | display: flex; 64 | flex-direction: column; 65 | gap: 8px; 66 | padding: 12px 16px; 67 | background: rgba(255, 255, 255, 0.2); 68 | border-radius: 8px; 69 | border: 1px solid rgba(255, 255, 255, 0.2); 70 | } 71 | 72 | .cfg-li span:first-child { 73 | font-size: 16px; 74 | font-weight: bold; 75 | color: #b0c4de; 76 | } 77 | 78 | .cfg-li span:last-child { 79 | font-size: 18px; 80 | font-weight: 400; 81 | color: #ffffff; 82 | line-height: 1.6; 83 | word-break: break-word; 84 | white-space: pre-wrap; 85 | } 86 | 87 | .cfg-li span:last-child.highlight { 88 | color: #ffd700; 89 | font-weight: 600; 90 | } 91 | 92 | -------------------------------------------------------------------------------- /resources/code/index.html: -------------------------------------------------------------------------------- 1 | {{extend defaultLayout}} 2 | 3 | {{block 'css'}} 4 | 5 | {{/block}} 6 | 7 | {{block 'main'}} 8 |
9 |
柠糖表情更新推送
10 |
当前分支:{{branchName}}
11 |
12 |
    13 |
  • 14 | 提交者: 15 | {{commitInfo.committer}} 16 |
  • 17 |
  • 18 | 提交时间: 19 | {{commitInfo.commitTime}} 20 |
  • 21 |
  • 22 | 提交标题: 23 | {{commitInfo.title}} 24 |
  • 25 |
  • 26 | 提交内容: 27 | {{commitInfo.content}} 28 |
  • 29 |
30 |
31 |
32 | {{/block}} 33 | -------------------------------------------------------------------------------- /resources/common/common.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Number"; 3 | src: url("https://static.wuliya.cn/static/font/TTTGB-Number.woff") format("woff"); 4 | font-display: swap 5 | } 6 | @font-face { 7 | font-family: "YS"; 8 | src: url("https://static.wuliya.cn/static/font/HYWH.woff") format("woff"); 9 | font-display: swap 10 | } 11 | @font-face { 12 | font-family: "Helvetica"; 13 | src: url("https://static.wuliya.cn/static/font/HELVETI.woff") format("woff"); 14 | font-display: swap 15 | } 16 | @font-face { 17 | font-family: "NotoColorEmoji"; 18 | src: url("https://static.wuliya.cn/static/font/NotoColorEmoji-Regular.ttf") format("truetype"); 19 | font-display: swap 20 | } 21 | 22 | * { 23 | margin: 0; 24 | padding: 0; 25 | box-sizing: border-box; 26 | -webkit-user-select: none; 27 | -moz-user-select: none; 28 | user-select: none; 29 | } 30 | 31 | body { 32 | color: #1e1f20; 33 | font-family: Number, YS, PingFangSC-Medium, NotoColorEmoji, sans-serif; 34 | transform-origin: 0 0; 35 | width: 100%; 36 | } 37 | 38 | .container { 39 | width: 1800px; 40 | padding: 20px 15px 10px 15px; 41 | background-size: contain; 42 | } 43 | 44 | .head-box { 45 | border-radius: 15px; 46 | padding: 10px 20px; 47 | position: relative; 48 | color: #fff; 49 | margin-top: 30px; 50 | } 51 | 52 | .head-box .title { 53 | font-family: Helvetica; 54 | font-style: italic; 55 | font-weight: bold; 56 | font-size: 36px; 57 | text-shadow: 0 0 1px #000, 1px 1px 3px rgba(0, 0, 0, 0.9); 58 | } 59 | 60 | .head-box .title .label { 61 | display: inline-block; 62 | margin-left: 10px; 63 | } 64 | 65 | .head-box .genshin_logo { 66 | position: absolute; 67 | top: 1px; 68 | right: 15px; 69 | width: 97px; 70 | } 71 | 72 | .head-box .label { 73 | font-size: 16px; 74 | text-shadow: 0 0 1px #000, 1px 1px 3px rgba(0, 0, 0, 0.9); 75 | } 76 | 77 | .head-box .label span { 78 | color: #d3bc8e; 79 | padding: 0 2px; 80 | } 81 | 82 | .notice { 83 | color: #888; 84 | font-size: 12px; 85 | text-align: right; 86 | padding: 12px 5px 5px; 87 | } 88 | 89 | .notice-center { 90 | color: #fff; 91 | text-align: center; 92 | margin-bottom: 10px; 93 | text-shadow: 1px 1px 1px #333; 94 | } 95 | 96 | .copyright { 97 | font-size: 15px; 98 | font-family: Helvetica; 99 | font-weight: bold; 100 | text-align: center; 101 | color: #fff; 102 | position: relative; 103 | padding-left: 10px; 104 | text-shadow: 1px 1px 1px #000; 105 | margin: 10px 0; 106 | } 107 | .copyright .version { 108 | color: #d3bc8e; 109 | display: inline-block; 110 | padding: 0 3px; 111 | } 112 | 113 | .copyright .commit_id_old { 114 | color: #808080; 115 | } 116 | 117 | .copyright .tip { 118 | color: #ff0000; 119 | } 120 | -------------------------------------------------------------------------------- /resources/common/layout/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {{_Plugin_AliasName}} 14 | {{block 'css'}} {{/block}} 15 | 16 | 17 | 18 |
19 | {{block 'main'}}{{/block}} 20 | 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /resources/help/icon.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CandriaJS/meme-plugin/9120261a4bf8057b77fbc911e955057fac8628df/resources/help/icon.webp -------------------------------------------------------------------------------- /resources/help/index.css: -------------------------------------------------------------------------------- 1 | .head-box { 2 | margin: 60px 0 0 0; 3 | padding-bottom: 0; 4 | } 5 | .head-box .title { 6 | font-size: 50px; 7 | } 8 | .cont-box { 9 | border-radius: 15px; 10 | margin-top: 20px; 11 | margin-bottom: 20px; 12 | overflow: hidden; 13 | box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.15); 14 | position: relative; 15 | } 16 | .help-group { 17 | font-size: 18px; 18 | font-weight: bold; 19 | padding: 15px 15px 10px 20px; 20 | } 21 | .help-table { 22 | text-align: center; 23 | border-collapse: collapse; 24 | margin: 0; 25 | border-radius: 0 0 10px 10px; 26 | display: table; 27 | overflow: hidden; 28 | width: 100%; 29 | color: #fff; 30 | } 31 | .help-table .tr { 32 | display: table-row; 33 | } 34 | .help-table .td, 35 | .help-table .th { 36 | font-size: 14px; 37 | display: table-cell; 38 | box-shadow: 0 0 1px 0 #888 inset; 39 | padding: 12px 0 12px 50px; 40 | line-height: 24px; 41 | position: relative; 42 | text-align: left; 43 | } 44 | .help-table .tr:last-child .td { 45 | padding-bottom: 12px; 46 | } 47 | .help-table .th { 48 | background: rgba(34, 41, 51, 0.5); 49 | } 50 | .help-icon { 51 | width: 40px; 52 | height: 40px; 53 | display: block; 54 | position: absolute; 55 | background: url("icon.webp") 0 0 no-repeat; 56 | background-size: 500px auto; 57 | border-radius: 5px; 58 | left: 6px; 59 | top: 12px; 60 | transform: scale(0.85); 61 | } 62 | .help-title { 63 | display: block; 64 | color: #d3bc8e; 65 | font-size: 16px; 66 | line-height: 24px; 67 | } 68 | .help-desc { 69 | display: block; 70 | color: #eee; 71 | font-size: 13px; 72 | line-height: 18px; 73 | } 74 | /*# sourceMappingURL=index.css.map */ 75 | -------------------------------------------------------------------------------- /resources/help/index.html: -------------------------------------------------------------------------------- 1 | {{extend defaultLayout}} {{block 'css'}} 2 | 3 | {{@style}} 4 | {{/block}} 5 | {{block 'main'}} 6 | 7 |
8 |
9 |
{{helpCfg.title}}
10 |
{{helpCfg.subTitle}}
11 |
12 |
13 | 14 | {{each helpGroup group}} {{set len = group?.list?.length || 0 }} 15 |
16 |
{{group.group}}
17 | {{if len > 0}} 18 |
19 |
20 | {{each group.list help idx}} 21 |
22 | {{/if}} 23 | {{help.title}} 24 | {{help.desc}} 25 |
26 | {{if idx%colCount === colCount-1 && idx>0 && idx< len-1}} 27 |
28 |
29 | {{/if}} 30 | {{/each}} 31 | {{set remainder = (colCount - (len % colCount)) % colCount}} 32 | {{if remainder > 0}} 33 | {{each [...Array(remainder)] item}} 34 |
35 | {{/each}} 36 | {{/if}} 37 |
38 |
39 | {{/if}} 40 |
41 | 42 | {{/each}} {{/block}} 43 | -------------------------------------------------------------------------------- /resources/help/theme/bg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CandriaJS/meme-plugin/9120261a4bf8057b77fbc911e955057fac8628df/resources/help/theme/bg.webp -------------------------------------------------------------------------------- /resources/help/theme/main.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CandriaJS/meme-plugin/9120261a4bf8057b77fbc911e955057fac8628df/resources/help/theme/main.webp -------------------------------------------------------------------------------- /resources/help/version-info.css: -------------------------------------------------------------------------------- 1 | 2 | .markdown-body { 3 | box-sizing: border-box; 4 | min-width: 200px; 5 | margin: 0 auto; 6 | padding: 45px; 7 | } 8 | 9 | @media (max-width: 767px) { 10 | .markdown-body { 11 | padding: 15px; 12 | } 13 | } -------------------------------------------------------------------------------- /resources/help/version-info.html: -------------------------------------------------------------------------------- 1 | 2 | {{block 'css'}} 3 | 8 | 13 | {{/block}} 14 | {{block 'main'}} 15 |
16 | {{@Markdown}} 17 |
18 | {{/block}} 19 | -------------------------------------------------------------------------------- /resources/list/imgs/bg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CandriaJS/meme-plugin/9120261a4bf8057b77fbc911e955057fac8628df/resources/list/imgs/bg.webp -------------------------------------------------------------------------------- /resources/list/imgs/icons/image.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/list/imgs/icons/option.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/list/imgs/icons/text.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/list/index.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background-image: url('./imgs/bg.webp'); 3 | background-size: 100% 100%; 4 | background-repeat: no-repeat; 5 | background-position: center; 6 | background-attachment: fixed; 7 | min-height: 100vh; 8 | } 9 | 10 | .meme-container { 11 | max-width: 1600px; 12 | margin: 0 auto; 13 | padding: 2rem; 14 | font-family: 'Helvetica Neue', Arial, sans-serif; 15 | } 16 | 17 | .meme-header { 18 | text-align: center; 19 | margin-bottom: 2rem; 20 | padding: 1.5rem; 21 | background: rgba(255, 255, 255, 0.9); 22 | border-radius: 12px; 23 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); 24 | } 25 | 26 | .meme-title { 27 | font-size: 2rem; 28 | font-weight: 600; 29 | color: #333; 30 | margin-bottom: 0.5rem; 31 | } 32 | 33 | .meme-count { 34 | font-size: 1rem; 35 | color: #666; 36 | } 37 | 38 | .meme-grid { 39 | display: grid; 40 | grid-template-columns: repeat(3, 1fr); 41 | gap: 1.5rem; 42 | padding: 1rem; 43 | } 44 | 45 | .meme-column { 46 | background: white; 47 | border-radius: 10px; 48 | padding: 1rem; 49 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); 50 | } 51 | 52 | .meme-item { 53 | display: flex; 54 | justify-content: space-between; 55 | align-items: center; 56 | padding: 0.85rem 1.2rem; 57 | margin-bottom: 0.75rem; 58 | background: #f8f9fa; 59 | border-radius: 8px; 60 | overflow: hidden; 61 | } 62 | 63 | .meme-name { 64 | font-size: 1rem; 65 | color: #333; 66 | white-space: nowrap; 67 | overflow: hidden; 68 | text-overflow: ellipsis; 69 | margin-right: 1rem; 70 | max-width: calc(100% - 80px); 71 | } 72 | 73 | .meme-icons { 74 | display: flex; 75 | gap: 0.75rem; 76 | flex-shrink: 0; 77 | } 78 | 79 | 80 | .meme-name { 81 | font-size: 0.95rem; 82 | color: #333; 83 | } 84 | 85 | .meme-icons { 86 | display: flex; 87 | gap: 0.5rem; 88 | } 89 | 90 | .icon { 91 | width: 18px; 92 | height: 18px; 93 | opacity: 0.7; 94 | } 95 | 96 | .meme-empty { 97 | text-align: center; 98 | color: #666; 99 | padding: 2rem; 100 | } 101 | -------------------------------------------------------------------------------- /resources/list/index.html: -------------------------------------------------------------------------------- 1 | {{extend defaultLayout}} 2 | 3 | {{block 'css'}} 4 | 5 | {{/block}} 6 | 7 | {{block 'main'}} 8 |
9 |
10 |

柠糖表情列表

11 |

表情总数:{{total}}

12 |
13 | 14 |
15 | {{if memeList.length > 0}} 16 | {{each memeList item index}} 17 | {{if index % 30 === 0}} 18 | {{if index !== 0}}
{{/if}} 19 |
20 |
    21 | {{/if}} 22 |
  • 23 | 24 | {{index + 1}}. {{item.name || '未知表情'}} 25 | 26 |
    27 | {{if item.types && item.types.includes('text')}} 28 | 文字表情 29 | {{/if}} 30 | {{if item.types && item.types.includes('image')}} 31 | 图片表情 32 | {{/if}} 33 | {{if item.types && item.types.includes('option')}} 34 | 参数表情 35 | {{/if}} 36 |
    37 |
  • 38 | {{/each}} 39 |
40 |
41 | {{else}} 42 |

暂无表情数据

43 | {{/if}} 44 |
45 | 46 | {{/block}} 47 | -------------------------------------------------------------------------------- /resources/server/imgs/bg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CandriaJS/meme-plugin/9120261a4bf8057b77fbc911e955057fac8628df/resources/server/imgs/bg.webp -------------------------------------------------------------------------------- /resources/server/status.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-image: url('./imgs/bg.webp'); 3 | } 4 | .info_box { 5 | background: rgba(255, 255, 255, 0.8); 6 | border-radius: 15px; 7 | padding: 20px; 8 | margin: 20px; 9 | box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1); 10 | } 11 | 12 | .head-box { 13 | text-align: center; 14 | margin-bottom: 30px; 15 | } 16 | 17 | .head-box .title { 18 | font-size: 24px; 19 | font-weight: bold; 20 | color: #333; 21 | margin-bottom: 10px; 22 | } 23 | 24 | .head-box .label { 25 | font-size: 16px; 26 | color: #666; 27 | } 28 | 29 | .card-container { 30 | max-width: 1200px; 31 | margin: 20px auto; 32 | padding: 20px; 33 | } 34 | 35 | .header-card { 36 | background: linear-gradient(135deg, #6e8efb, #a777e3); 37 | border-radius: 15px; 38 | padding: 30px; 39 | text-align: center; 40 | color: white; 41 | margin-bottom: 30px; 42 | box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); 43 | } 44 | 45 | .header-card .title { 46 | font-size: 28px; 47 | font-weight: bold; 48 | margin-bottom: 10px; 49 | } 50 | 51 | .header-card .subtitle { 52 | font-size: 16px; 53 | opacity: 0.9; 54 | } 55 | 56 | .status-grid { 57 | display: grid; 58 | grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); 59 | gap: 20px; 60 | } 61 | 62 | .status-card { 63 | background: white; 64 | border-radius: 12px; 65 | padding: 20px; 66 | text-align: center; 67 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); 68 | transition: all 0.3s ease; 69 | } 70 | 71 | .status-card:hover { 72 | transform: translateY(-5px); 73 | box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1); 74 | } 75 | 76 | .card-icon { 77 | font-size: 32px; 78 | margin-bottom: 15px; 79 | } 80 | 81 | .card-label { 82 | color: #666; 83 | font-size: 14px; 84 | margin-bottom: 8px; 85 | } 86 | 87 | .card-value { 88 | color: #333; 89 | font-size: 20px; 90 | font-weight: bold; 91 | } 92 | 93 | @media (max-width: 768px) { 94 | .status-grid { 95 | grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); 96 | gap: 15px; 97 | } 98 | 99 | .header-card { 100 | padding: 20px; 101 | } 102 | 103 | .header-card .title { 104 | font-size: 24px; 105 | } 106 | } 107 | 108 | .status-item { 109 | background: rgba(255, 255, 255, 0.9); 110 | border-radius: 10px; 111 | padding: 15px; 112 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); 113 | transition: transform 0.2s; 114 | } 115 | 116 | .status-item:hover { 117 | transform: translateY(-2px); 118 | } 119 | 120 | .status-label { 121 | font-size: 14px; 122 | color: #666; 123 | margin-bottom: 8px; 124 | } 125 | 126 | .status-value { 127 | font-size: 18px; 128 | color: #333; 129 | font-weight: bold; 130 | } -------------------------------------------------------------------------------- /resources/server/status.html: -------------------------------------------------------------------------------- 1 | {{extend defaultLayout}} 2 | 3 | {{block 'css'}} 4 | 5 | {{/block}} 6 | 7 | {{block 'main'}} 8 |
9 |
10 |
柠糖表情服务端状态
11 |
服务器运行信息
12 |
13 | 14 |
15 | 16 |
17 |
📦
18 |
插件版本
19 |
{{version}}
20 |
21 | 22 |
23 |
🔖
24 |
服务端版本
25 |
{{serverVersion}}
26 |
27 | 28 |
29 |
🚥
30 |
运行状态
31 |
{{status}}
32 |
33 | 34 |
35 |
36 |
启动时间
37 |
{{runtime}}
38 |
39 | 40 |
41 |
💾
42 |
内存使用
43 |
{{memory}} MB
44 |
45 | 46 |
47 |
🖼️
48 |
表情总数
49 |
{{total}}
50 |
51 | 52 |
53 |
54 | {{/block}} 55 | -------------------------------------------------------------------------------- /resources/stat/imgs/bg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CandriaJS/meme-plugin/9120261a4bf8057b77fbc911e955057fac8628df/resources/stat/imgs/bg.webp -------------------------------------------------------------------------------- /resources/stat/index.css: -------------------------------------------------------------------------------- 1 | .stat-container { 2 | background-image: url('./imgs/bg.webp'); 3 | background-size: cover; 4 | background-repeat: no-repeat; 5 | background-position: center; 6 | padding: 20px; 7 | border-radius: 20px; 8 | text-align: center; 9 | box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15); 10 | border: 1px solid rgba(0, 0, 0, 0.1); 11 | backdrop-filter: blur(10px); 12 | } 13 | 14 | .stat-title { 15 | font-size: 36px; 16 | font-weight: 800; 17 | color: rgb(35, 65, 100); 18 | text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1); 19 | margin-bottom: 20px; 20 | letter-spacing: 1px; 21 | position: relative; 22 | display: inline-block; 23 | padding-bottom: 8px; 24 | } 25 | 26 | .stat-title::after { 27 | content: ''; 28 | position: absolute; 29 | bottom: 0; 30 | left: 50%; 31 | transform: translateX(-50%); 32 | width: 60px; 33 | height: 3px; 34 | background: rgba(70, 130, 180, 0.4); 35 | border-radius: 2px; 36 | } 37 | 38 | .stat-total { 39 | font-size: 18px; 40 | font-weight: 600; 41 | color: rgb(70, 110, 150); 42 | text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.05); 43 | margin-bottom: 25px; 44 | } 45 | 46 | .stat-list { 47 | display: grid; 48 | grid-template-columns: repeat(5, 1fr); 49 | gap: 1rem; 50 | padding: 1rem; 51 | background: rgba(255, 255, 255, 0.85); 52 | border-radius: 12px; 53 | box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08); 54 | } 55 | 56 | .stat-item { 57 | display: flex; 58 | justify-content: space-between; 59 | align-items: center; 60 | padding: 12px 16px; 61 | background: linear-gradient(to right, #f8f9fa, #ffffff); 62 | border-left: 3px solid #4a6e91; 63 | border-radius: 8px; 64 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 65 | } 66 | 67 | .stat-keyword { 68 | font-size: 16px; 69 | font-weight: 600; 70 | color: #4a6e91; 71 | flex: 1; 72 | text-align: left; 73 | } 74 | 75 | .stat-count { 76 | font-size: 14px; 77 | font-weight: bold; 78 | color: #558fc8; 79 | margin-left: 12px; 80 | white-space: nowrap; 81 | background-color: rgba(85, 143, 200, 0.1); 82 | padding: 2px 8px; 83 | border-radius: 12px; 84 | } -------------------------------------------------------------------------------- /resources/stat/index.html: -------------------------------------------------------------------------------- 1 | {{extend defaultLayout}} 2 | 3 | {{block 'css'}} 4 | 5 | {{/block}} 6 | 7 | {{block 'main'}} 8 |
9 |

柠糖表情统计

10 |
表情调用总次数:{{total}}
11 |
12 | {{each memeList item}} 13 |
14 |
{{item.keywords}}
15 |
{{item.count}} 次
16 |
17 | {{/each}} 18 |
19 |
20 | {{/block}} --------------------------------------------------------------------------------