├── .github └── workflows │ └── pypi-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── guess_chart_screenshot.png ├── guess_cover_screenshot.png └── open_character_screenshot.png ├── nonebot_plugin_guess_song ├── .prettierrc ├── __init__.py ├── config.py └── libraries │ ├── __init__.py │ ├── guess_character.py │ ├── guess_cover.py │ ├── guess_song_chart.py │ ├── guess_song_clue.py │ ├── guess_song_listen.py │ ├── guess_song_note.py │ ├── music_model.py │ └── utils.py ├── pyproject.toml └── so_hard.jpg /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | pypi-publish: 11 | name: Upload release to PyPI 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@master 15 | - name: Set up Python 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: "3.x" 19 | - name: Install pypa/build 20 | run: >- 21 | python -m 22 | pip install 23 | build 24 | --user 25 | - name: Build a binary wheel and a source tarball 26 | run: >- 27 | python -m 28 | build 29 | --sdist 30 | --wheel 31 | --outdir dist/ 32 | . 33 | - name: Publish distribution to PyPI 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | with: 36 | password: ${{ secrets.PYPI_API_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 104 | poetry.toml 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/#use-with-ide 112 | .pdm-python 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # ruff 158 | .ruff_cache/ 159 | 160 | # LSP config files 161 | pyrightconfig.json 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # VisualStudioCode 171 | .vscode/* 172 | !.vscode/settings.json 173 | !.vscode/tasks.json 174 | !.vscode/launch.json 175 | !.vscode/extensions.json 176 | !.vscode/*.code-snippets 177 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 CainithmBot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | NoneBotPluginLogo 3 |
4 |

NoneBotPluginText

5 |
6 | 7 |
8 | 9 | # nonebot-plugin-guess-song 10 | 11 | _✨ NoneBot 舞萌猜歌小游戏插件 ✨_ 12 | 13 | 14 | 15 | license 16 | 17 | 18 | pypi 19 | 20 | python 21 | 22 |
23 | 24 | 25 | ## 📖 介绍 26 | 27 | 一个音游猜歌插件(主要为舞萌DX maimaiDX提供资源),有开字母、猜曲绘、听歌猜曲、谱面猜歌、线索猜歌、note音猜歌等游戏 28 | 29 | ## 💿 安装 30 | 31 |
32 | 使用 nb-cli 安装 33 | 在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装 34 | 35 | nb plugin install nonebot-plugin-guess-song 36 | 37 |
38 | 39 |
40 | 使用包管理器安装 41 | 在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令 42 | 43 |
44 | pip 45 | 46 | pip install nonebot-plugin-guess-song 47 |
48 |
49 | pdm 50 | 51 | pdm add nonebot-plugin-guess-song 52 |
53 |
54 | poetry 55 | 56 | poetry add nonebot-plugin-guess-song 57 |
58 |
59 | conda 60 | 61 | conda install nonebot-plugin-guess-song 62 |
63 | 64 | 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分追加写入 65 | 66 | plugins = ["nonebot_plugin_guess_song"] 67 | 68 |
69 | 70 | ## ⚙️ 配置 71 | 72 | ⚠️⚠️⚠️请务必注意,若需要使用听歌猜曲以及谱面猜歌,必须先下载并配置ffmpeg⚠️⚠️⚠️ 73 | 74 | 75 | 在 nonebot2 项目的`.env`文件中添加下表中的必填配置 76 | 77 | | 配置项 | 必填 | 默认值 | 说明 | 78 | |:-----:|:----:|:----:|:----:| 79 | | character_filter_japenese | 否 | True | 指示开字母游戏中,是否需要过滤掉含有日文字符的歌曲 | 80 | | everyday_is_add_credits | 否 | False | 指示是否需要每天给猜歌答对的用户加积分 | 81 | 82 | 83 | ### 资源需求 84 | 本插件需要一些资源(歌曲信息等)才能够正常使用,以下是说明及下载方法、配置教程。 85 | 86 | 💡**资源需求说明**: 87 | - 最低配置(所有游戏都需要):music_data.json、music_alias.json(在后文的static压缩包中) 88 | - 开字母:无需添加其它资源(可以按需添加[“这么难他都会”](https://github.com/apshuang/nonebot-plugin-guess-song/blob/master/so_hard.jpg)的图片资源到/resources/maimai/so_hard.jpg) 89 | - 猜曲绘:需要添加mai/cover(也在后文的static压缩包中) 90 | - 线索猜歌:需要添加mai/cover(如果添加了音乐资源,还会增加歌曲长度作为线索) 91 | - 听歌猜曲:需要添加music_guo——在动态资源路径/resources下,建立music_guo文件夹,将听歌猜曲文件放入内; 92 | - 谱面猜歌:需要添加chart_resources——在动态资源路径/resources下,建立chart_resources文件夹,将谱面猜歌资源文件放入内(保持每个版本一个文件夹)。 93 | - note音猜歌:资源需求同谱面猜歌。 94 | 95 | 96 | 🟩**static文件**:[GitHub Releases下载](https://github.com/apshuang/nonebot-plugin-guess-song/releases/tag/Static-resources)、[百度云盘](https://pan.baidu.com/s/1K5d7MqcNh83gh9yerfgGPg?pwd=fiwp) 97 | 98 | 内部包括music_data.json、music_alias.json文件,以及mai/cover,是歌曲的信息与别名,以及歌曲的曲绘。 99 | 推荐联合使用[其它maimai插件](https://github.com/Yuri-YuzuChaN/nonebot-plugin-maimaidx)来动态更新歌曲信息与别名信息。 100 | 101 | 102 | 🟩**动态资源文件**(针对听歌猜曲和谱面猜歌,可**按需下载**): 103 | - 听歌猜曲文件(共6.55GB,已切分为五个压缩包,可部分下载):[GitHub Releases下载](https://github.com/apshuang/nonebot-plugin-guess-song/releases/tag/guess_listen-resources)、[百度云盘](https://pan.baidu.com/s/1vVC8p7HDWfczMswOLmE8Og?pwd=gqu3) 104 | - 谱面猜歌文件(共22.37GB,已划分为按版本聚类,可下载部分版本使用):[GitHub Releases下载](https://github.com/apshuang/nonebot-plugin-guess-song/releases/tag/guess_chart-resources)、[百度云盘](https://pan.baidu.com/s/1kIMeYv46djxJe_p8DMTtfA?pwd=e6sf) 105 | 106 | 在动态资源路径/resources下,建立music_guo文件夹,将听歌猜曲文件放入内;建立chart_resources文件夹,将谱面猜歌资源文件放入内(保持每个版本一个文件夹)。 107 | ⚠️可以不下载动态资源, 也可以只下载部分动态资源(部分歌曲或部分版本),插件也能正常运行,只不过曲库会略微少一些。⚠️ 108 | 109 | ### 资源目录配置教程 110 |
111 | 点击展开查看项目目录 112 | 113 | ```plaintext 114 | CainithmBot/ 115 | ├── static/ 116 | │ ├── mai/ 117 | │ │ └── cover/ 118 | │ ├── music_data.json # 歌曲信息 119 | │ ├── music_alias.json # 歌曲别名信息 120 | │ └── SourceHanSansSC-Bold.otf # 发送猜歌帮助所需字体 121 | ├── resources/ 122 | │ ├── music_guo/ # 国服歌曲音乐文件 123 | │ │ ├── 8.mp3 124 | │ │ └── ... 125 | │ ├── chart_resources/ # 谱面猜歌资源文件 126 | │ │ ├── 01. maimai/ # 内部需按版本分开各个文件夹 127 | │ │ │ ├── mp3/ 128 | │ │ │ └── mp4/ 129 | │ │ ├── 02. maimai PLUS/ 130 | │ │ │ ├── mp3/ 131 | │ │ │ └── mp4/ 132 | │ │ └── ... 133 | │ │ ├── remaster/ 134 | │ │ │ ├── mp3/ 135 | │ │ │ └── mp4/ 136 | └── ... 137 | 138 | ``` 139 |
140 | 141 | 142 | 根据上面的项目目录,我们可以看到,在某个存放资源的根目录(假设这个根目录为`E:/Bot/CainithmBot`)下面,有一个static文件夹和一个resources文件夹,只需要按照上面的指示,将对应的资源放到对应文件夹目录下即可。 143 | 同时,本插件使用了nonebot-plugin-localstore插件进行资源管理,所以,比较建议您为本插件单独设置一个资源根目录(因为资源较多),我们继续假设这个资源根目录为`E:/Bot/CainithmBot`,那么您需要在`.env`配置文件中添加以下配置: 144 | ```dotenv 145 | LOCALSTORE_PLUGIN_DATA_DIR=' 146 | { 147 | "nonebot_plugin_guess_song": "E:/Bot/CainithmBot" 148 | } 149 | ' 150 | ``` 151 | 如果您希望直接使用一个全局的资源根目录,则直接通过`nb localstore`,查看全局的Data Dir,并将static和resources文件夹置于这个Data Dir下,就可以使用了! 152 | 153 | 154 | ## 🎉 使用 155 | ### 指令表 156 | 详细指令表及其高级用法(比如过滤某个版本、某些等级的歌曲)也可以通过“/猜歌帮助”来查看 157 | | 指令 | 权限 | 需要@ | 范围 | 说明 | 158 | |:-----:|:----:|:----:|:----:|:----:| 159 | | /开字母 | 群员 | 否 | 群聊 | 开始开字母游戏 | 160 | | /(连续)听歌猜曲 | 群员 | 否 | 群聊 | 开始(连续)听歌猜曲游戏 | 161 | | /(连续)谱面猜歌 | 群员 | 否 | 群聊 | 开始(连续)谱面猜歌游戏 | 162 | | /(连续)猜曲绘 | 群员 | 否 | 群聊 | 开始(连续)猜曲绘游戏 | 163 | | /(连续)线索猜歌 | 群员 | 否 | 群聊 | 开始(连续)线索猜歌游戏 | 164 | | /(连续)note音猜歌 | 群员 | 否 | 群聊 | 开始(连续)note音猜歌游戏 | 165 | | /(连续)随机猜歌 | 群员 | 否 | 群聊 | 在听歌猜曲、谱面猜歌、猜曲绘、线索猜歌中随机进行一个游戏 | 166 | | 猜歌 xxx | 群员 | 否 | 群聊 | 根据已知信息猜测歌曲 | 167 | | 不玩了 | 群员 | 否 | 群聊 | 揭晓当前猜歌的答案 | 168 | | 停止 | 群员 | 否 | 群聊 | 停止连续猜歌 | 169 | | /开启/关闭猜歌 xxx | 管理员/主人 | 否 | 群聊 | 开启或禁用某类或全部猜歌游戏 | 170 | | /猜曲绘配置 xxx | 管理员/主人 | 否 | 群聊 | 进行猜曲绘配置(高斯模糊程度、打乱程度、裁切程度等) | 171 | | /检查歌曲文件完整性 | 主人 | 否 | 群聊 | 检查听歌猜曲的音乐资源 | 172 | | /检查谱面完整性 | 主人 | 否 | 群聊 | 检查谱面猜歌的文件资源 | 173 | 174 | 175 | ## 📝 项目特点 176 | 177 | - ✅ 游戏新颖、有趣,更贴合游戏的核心要素(谱面与音乐) 178 | - ✅ 资源配置要求较低,可按需部分下载 179 | - ✅ 性能较高,使用preload技术加快谱面加载速度 180 | - ✅ 框架通用,可扩展性强 181 | - ✅ 使用简单、可使用别名猜歌,用户猜歌方便 182 | - ✅ 贡献了谱面猜歌数据集,可以用于制作猜歌视频 183 | 184 | 185 | ### 效果图 186 | ![开字母效果图](./docs/open_character_screenshot.png) 187 | ![猜曲绘效果图](./docs/guess_cover_screenshot.png) 188 | ![谱面猜歌效果图](./docs/guess_chart_screenshot.png) 189 | 190 | 191 | ## 🙏 鸣谢 192 | 193 | - [maimai插件](https://github.com/Yuri-YuzuChaN/nonebot-plugin-maimaidx) - maimai插件、static资源下载 194 | - [MajdataEdit](https://github.com/LingFeng-bbben/MajdataEdit) - maimai谱面编辑器 195 | - [MajdataEdit_BatchOutput_Tool](https://github.com/apshuang/MajdataEdit_BatchOutput_Tool) - 用于批量导出maimai谱面视频资源 196 | - [NoneBot2](https://github.com/nonebot/nonebot2) - 跨平台 Python 异步机器人框架 197 | 198 | 199 | ## 📞 联系 200 | 201 | | 猜你字母Bot游戏群(欢迎加群游玩) | QQ群:925120177 | 202 | | ---------------- | ---------------- | 203 | 204 | 发现任何问题或有任何建议,欢迎发Issue,也可以加群联系开发团队,感谢! -------------------------------------------------------------------------------- /docs/guess_chart_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apshuang/nonebot-plugin-guess-song/7bfc3ed440f5e982360d663872f4a3d509184392/docs/guess_chart_screenshot.png -------------------------------------------------------------------------------- /docs/guess_cover_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apshuang/nonebot-plugin-guess-song/7bfc3ed440f5e982360d663872f4a3d509184392/docs/guess_cover_screenshot.png -------------------------------------------------------------------------------- /docs/open_character_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apshuang/nonebot-plugin-guess-song/7bfc3ed440f5e982360d663872f4a3d509184392/docs/open_character_screenshot.png -------------------------------------------------------------------------------- /nonebot_plugin_guess_song/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /nonebot_plugin_guess_song/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot import get_plugin_config, get_bot 2 | from nonebot import on_startswith, on_command, on_fullmatch, on_message 3 | from nonebot.plugin import PluginMetadata 4 | from nonebot.adapters.onebot.v11 import Message, MessageSegment, GroupMessageEvent, Bot 5 | from nonebot.params import Startswith 6 | from nonebot.matcher import Matcher 7 | from nonebot.plugin import PluginMetadata, require 8 | 9 | from .libraries import * 10 | from .config import * 11 | 12 | 13 | require('nonebot_plugin_apscheduler') 14 | 15 | from nonebot_plugin_apscheduler import scheduler 16 | 17 | __plugin_meta__ = PluginMetadata( 18 | name="maimai猜歌小游戏", 19 | description="音游猜歌游戏插件,提供开字母、听歌猜曲、谱面猜歌、猜曲绘、线索猜歌等游戏", 20 | usage="/猜歌帮助", 21 | type="application", 22 | config=Config, 23 | homepage="https://github.com/apshuang/nonebot-plugin-guess-song", 24 | supported_adapters={"~onebot.v11"}, 25 | ) 26 | 27 | 28 | def is_now_playing_game(event: GroupMessageEvent) -> bool: 29 | return gameplay_list.get(str(event.group_id)) is not None 30 | 31 | open_song = on_startswith(("开歌","猜歌"), priority=3, ignorecase=True, block=True, rule=is_now_playing_game) 32 | open_song_without_prefix = on_message(rule=is_now_playing_game, priority=10) 33 | stop_game = on_fullmatch(("不玩了", "不猜了"), priority=5) 34 | stop_game_force = on_fullmatch("强制停止", priority=5) 35 | stop_continuous = on_fullmatch('停止', priority=5) 36 | top_three = on_command('前三', priority=5) 37 | guess_random = on_command('随机猜歌', priority=5) 38 | continuous_guess_random = on_command('连续随机猜歌', priority=5) 39 | help_guess = on_command('猜歌帮助', priority=5) 40 | charter_names = on_command('查看谱师', priority=5) 41 | enable_guess_game = on_command('开启猜歌', priority=5, permission=GROUP_ADMIN | GROUP_OWNER | SUPERUSER) 42 | disable_guess_game = on_command('关闭猜歌', priority=5, permission=GROUP_ADMIN | GROUP_OWNER | SUPERUSER) 43 | 44 | @help_guess.handle() 45 | async def _(matcher: Matcher): 46 | await matcher.finish(MessageSegment.image(to_bytes_io(guess_help_message))) 47 | 48 | @charter_names.handle() 49 | async def _(bot: Bot, event: GroupMessageEvent): 50 | msglist = [] 51 | for names in charterlist.values(): 52 | msglist.append(', '.join(names)) 53 | msglist.append('以上是目前已知的谱师名单,如有遗漏请联系管理员添加。') 54 | await send_forward_message(bot, event.group_id, bot.self_id, msglist) 55 | 56 | @guess_random.handle() 57 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()): 58 | if len(total_list.music_list) == 0: 59 | await matcher.finish("本插件还没有配置好static资源噢,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!") 60 | group_id = str(event.group_id) 61 | game_name = "random" 62 | if check_game_disable(group_id, game_name): 63 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!") 64 | params = args.extract_plain_text().strip().split() 65 | await isplayingcheck(group_id, matcher) 66 | choice = random.randint(1, 4) 67 | if choice == 1: 68 | await guess_cover_handler(group_id, matcher, params) 69 | elif choice == 2: 70 | await clue_guess_handler(group_id, matcher, params) 71 | elif choice == 3: 72 | await listen_guess_handler(group_id, matcher, params) 73 | elif choice == 4: 74 | await guess_chart_request(event, matcher, args) 75 | elif choice == 5: 76 | await note_guess_handler(group_id, matcher, params) 77 | 78 | @continuous_guess_random.handle() 79 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()): 80 | if len(total_list.music_list) == 0: 81 | await matcher.finish("本插件还没有配置好static资源噢,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!") 82 | group_id = str(event.group_id) 83 | game_name = "random" 84 | if check_game_disable(group_id, game_name): 85 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!") 86 | params = args.extract_plain_text().strip().split() 87 | await isplayingcheck(group_id, matcher) 88 | if not filter_random(total_list.music_list, params, 1): 89 | await matcher.finish(fault_tips, reply_message=True) 90 | await matcher.send('连续随机猜歌已开启,发送\"停止\"以结束') 91 | continuous_stop[group_id] = 1 92 | 93 | charts_pool = None 94 | # 此处开始preload一些谱面(如果是有参数的) 95 | if len(params) != 0: 96 | # 有参数的连续谱面猜歌 97 | valid_music = filter_random(chart_total_list, params, 10) # 至少需要有10首歌,不然失去“猜”的价值 98 | if not valid_music: 99 | continuous_stop.pop(group_id) 100 | await matcher.finish(fault_tips) 101 | 102 | groupID_params_map[group_id] = params 103 | sub_path_name = f"{str(event.group_id)}_{'_'.join(params)}" 104 | param_charts_pool.setdefault(sub_path_name, []) 105 | charts_pool = param_charts_pool[sub_path_name] 106 | 107 | # 先进行检验,查看旧的charts_pool里的文件是否都还存在(未被删除) 108 | charts_already_old = [] 109 | for (question_clip_path, answer_clip_path) in charts_pool: 110 | if not os.path.isfile(question_clip_path) or not os.path.isfile(answer_clip_path): 111 | charts_already_old.append((question_clip_path, answer_clip_path)) 112 | for (question_clip_path, answer_clip_path) in charts_already_old: 113 | charts_pool.remove((question_clip_path, answer_clip_path)) 114 | logging.info(f"删除了过时的谱面:{question_clip_path}") 115 | make_param_chart_task = asyncio.create_task(make_param_chart_video(group_id, params)) 116 | else: 117 | charts_pool = general_charts_pool 118 | 119 | 120 | while continuous_stop.get(group_id): 121 | if gameplay_list.get(group_id) is None: 122 | choice = random.randint(1, 4) 123 | if choice == 1: 124 | await guess_cover_handler(group_id, matcher, params) 125 | elif choice == 2: 126 | await clue_guess_handler(group_id, matcher, params) 127 | elif choice == 3: 128 | await listen_guess_handler(group_id, matcher, params) 129 | elif choice == 4: 130 | for i in range(30): 131 | if len(charts_pool) > 0: 132 | break 133 | await asyncio.sleep(1) 134 | if len(charts_pool) == 0: 135 | continuous_stop.pop(group_id) 136 | if groupID_params_map.get(group_id): 137 | groupID_params_map.pop(group_id) 138 | await matcher.finish("bot主的电脑太慢啦,过了30秒还没有一个谱面制作出来!建议vivo50让我换电脑!", reply_message=True) 139 | (question_clip_path, answer_clip_path) = charts_pool.pop(random.randint(0, len(charts_pool) - 1)) 140 | await guess_chart_handler(group_id, matcher, question_clip_path, answer_clip_path, params) 141 | await asyncio.sleep(2) 142 | elif choice == 5: 143 | await note_guess_handler(group_id, matcher, params) 144 | await asyncio.sleep(2) 145 | if continuous_stop[group_id] > 3: 146 | continuous_stop.pop(group_id) 147 | if groupID_params_map.get(group_id): 148 | groupID_params_map.pop(group_id) 149 | await matcher.finish('没人猜了? 那我下班了。') 150 | await asyncio.sleep(1) 151 | 152 | async def open_song_dispatcher(matcher: Matcher, song_name, user_id, group_id, ignore_tag=False): 153 | if gameplay_list.get(group_id).get("open_character"): 154 | await character_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag) 155 | elif gameplay_list.get(group_id).get("listen"): 156 | await listen_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag) 157 | elif gameplay_list.get(group_id).get("cover"): 158 | await cover_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag) 159 | elif gameplay_list.get(group_id).get("clue"): 160 | await clue_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag) 161 | elif gameplay_list.get(group_id).get("chart"): 162 | await chart_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag) 163 | elif gameplay_list.get(group_id).get("note"): 164 | await note_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag) 165 | 166 | @open_song.handle() 167 | async def open_song_handler(event: GroupMessageEvent, matcher: Matcher, start: str = Startswith()): 168 | song_name = event.get_plaintext().lower()[len(start):].strip() 169 | if song_name == "": 170 | await matcher.finish("无效的请求,请使用格式:开歌xxxx(xxxx 是歌曲名称)", reply_message=True) 171 | await open_song_dispatcher(matcher, song_name, event.user_id, str(event.group_id)) 172 | 173 | 174 | @open_song_without_prefix.handle() 175 | async def open_song_without_prefix_handler(event: GroupMessageEvent, matcher: Matcher): 176 | # 直接输入曲名也可以视作答题,但是答题错误的话不返回任何信息(防止正常聊天也被视作答题) 177 | song_name = event.get_plaintext().strip().lower() 178 | if song_name == "" or alias_dict.get(song_name) is None: 179 | return 180 | await open_song_dispatcher(matcher, song_name, event.user_id, str(event.group_id), True) 181 | 182 | 183 | @stop_game_force.handle() 184 | async def _(event: GroupMessageEvent, matcher: Matcher): 185 | # 中途停止的处理函数 186 | group_id = str(event.group_id) 187 | if gameplay_list.get(group_id): 188 | gameplay_list.pop(group_id) 189 | if continuous_stop.get(group_id): 190 | continuous_stop.pop(group_id) 191 | if groupID_params_map.get(group_id): 192 | groupID_params_map.pop(group_id) 193 | await matcher.finish("已强行停止当前游戏") 194 | 195 | 196 | @stop_game.handle() 197 | async def _(event: GroupMessageEvent, matcher: Matcher): 198 | # 中途停止的处理函数 199 | group_id = str(event.group_id) 200 | if gameplay_list.get(group_id) is not None: 201 | now_playing_game = list(gameplay_list.get(group_id).keys())[0] 202 | if now_playing_game == "open_character": 203 | message = open_character_message(group_id, early_stop = True) 204 | await matcher.finish(message) 205 | elif now_playing_game in ["listen", "cover", "clue"]: 206 | music = gameplay_list.get(group_id).get(now_playing_game) 207 | gameplay_list.pop(group_id) 208 | await matcher.finish( 209 | MessageSegment.text(f'很遗憾,你没有猜到答案,正确的答案是:\n') + song_txt(music) + MessageSegment.text('\n\n。。30秒都坚持不了吗') 210 | ,reply_message=True) 211 | elif now_playing_game == "chart": 212 | answer_clip_path = gameplay_list.get(group_id).get("chart") 213 | gameplay_list.pop(group_id) # 需要先pop,不然的话发了答案之后还能猜 214 | random_music_id, start_time = split_id_from_path(answer_clip_path) 215 | is_remaster = False 216 | if int(random_music_id) > 500000: 217 | # 是白谱,需要找回原始music对象 218 | random_music_id = str(int(random_music_id) % 500000) 219 | is_remaster = True 220 | random_music = total_list.by_id(random_music_id) 221 | charts_save_list.append(answer_clip_path) # 但是必须先把它保护住,否则有可能发到一半被删掉 222 | reply_message = MessageSegment.text("很遗憾,你没有猜到答案,正确的答案是:\n") + song_txt(random_music, is_remaster) + "\n对应的原片段如下:" 223 | await matcher.send(reply_message, reply_message=True) 224 | if answer_clip_path in loading_clip_list: 225 | for i in range(31): 226 | await asyncio.sleep(1) 227 | if answer_clip_path not in loading_clip_list: 228 | break 229 | if i == 30: 230 | await matcher.finish(f"答案文件可能坏掉了,这个谱的开始时间是{start_time}秒,如果感兴趣可以自己去搜索噢", reply_message=True) 231 | await matcher.send(MessageSegment.video(f"file://{answer_clip_path}")) 232 | os.remove(answer_clip_path) 233 | charts_save_list.remove(answer_clip_path) # 发完了就可以解除保护了 234 | elif now_playing_game == "note": 235 | (answer_music, start_time) = gameplay_list.get(group_id).get(now_playing_game) 236 | random_music_id = answer_music.id 237 | is_remaster = False 238 | if int(random_music_id) > 500000: 239 | # 是白谱,需要找回原始music对象 240 | random_music_id = str(int(random_music_id) % 500000) 241 | is_remaster = True 242 | random_music = total_list.by_id(random_music_id) 243 | gameplay_list.pop(group_id) 244 | await matcher.send( 245 | MessageSegment.text(f'很遗憾,你没有猜到答案,正确的答案是:\n') + song_txt(random_music, is_remaster) + MessageSegment.text('\n\n。。30秒都坚持不了吗') 246 | ,reply_message=True) 247 | answer_input_path = id_mp3_file_map.get(answer_music.id) # 这个是旧的id,即白谱应该找回白谱的id 248 | answer_output_path: Path = guess_resources_path / f"{group_id}_{start_time}_answer.mp3" 249 | await asyncio.to_thread(make_note_sound, answer_input_path, start_time, answer_output_path) 250 | await matcher.send(MessageSegment.record(f"file://{answer_output_path}")) 251 | os.remove(answer_output_path) 252 | 253 | @stop_continuous.handle() 254 | async def _(event: GroupMessageEvent, matcher: Matcher): 255 | group_id = str(event.group_id) 256 | if groupID_params_map.get(group_id): 257 | groupID_params_map.pop(group_id) 258 | if continuous_stop.get(group_id): 259 | continuous_stop.pop(group_id) 260 | await matcher.finish('已停止,坚持把最后一首歌猜完吧!') 261 | 262 | def add_credit_message(group_id) -> list[str]: 263 | record = {} 264 | gid = str(group_id) 265 | game_data = load_game_data_json(gid) 266 | for game_name, data in game_data[gid]['rank'].items(): 267 | for user_id, point in data.items(): 268 | record.setdefault(user_id, [0, 0]) 269 | record[user_id][0] += point 270 | record[user_id][1] += point // point_per_credit_dict[game_name] 271 | sorted_record = sorted(record.items(), key=lambda x: (x[1][1], x[1][0]), reverse=True) 272 | if sorted_record: 273 | msg_list = [] 274 | msg = f'今日猜歌总记录:\n' 275 | for rank, (user_id, count) in enumerate(sorted_record, 1): 276 | if (rank-1) % 30 == 0 and rank != 1: 277 | msg_list.append(msg) 278 | msg = "" 279 | if config.everyday_is_add_credits: 280 | msg += f"{rank}. {MessageSegment.at(user_id)} 今天共答对{count[0]}题,加{count[1]}分!\n" 281 | # -------------请在此处填写你的加分代码(如需要)------------------ 282 | 283 | 284 | # -------------请在此处填写你的加分代码(如需要)------------------ 285 | else: 286 | msg += f"{rank}. {MessageSegment.at(user_id)} 今天共答对{count[0]}题!\n" 287 | if config.everyday_is_add_credits: 288 | msg += "便宜你们了。。" 289 | msg_list.append(msg) 290 | return msg_list 291 | else: 292 | return [] 293 | 294 | async def send_top_three(bot, group_id, isaddcredit = False, is_force = False): 295 | sender_id = bot.self_id 296 | char_msg = open_character_rank_message(group_id) 297 | listen_msg = listen_rank_message(group_id) 298 | cover_msg = cover_rank_message(group_id) 299 | clue_msg = clue_rank_message(group_id) 300 | chart_msg = chart_rank_message(group_id) 301 | note_msg = note_rank_message(group_id) 302 | 303 | origin_messages = [char_msg, listen_msg, cover_msg, clue_msg, chart_msg, note_msg] 304 | if isaddcredit: 305 | origin_messages.extend(add_credit_message(group_id)) 306 | if is_force: 307 | empty_tag = True 308 | for msg in origin_messages: 309 | if msg is not None: 310 | empty_tag = False 311 | if empty_tag: 312 | await bot.send_group_msg(group_id=group_id, message=Message(MessageSegment.text("本群还没有猜歌排名数据噢!快来玩一下猜歌游戏吧!"))) 313 | return 314 | await send_forward_message(bot, group_id, sender_id, origin_messages) 315 | 316 | 317 | @enable_guess_game.handle() 318 | @disable_guess_game.handle() 319 | async def _(matcher: Matcher, event: GroupMessageEvent, arg: Message = CommandArg()): 320 | gid = str(event.group_id) 321 | arg = arg.extract_plain_text().strip().lower() 322 | enable_sign = True 323 | if type(matcher) is enable_guess_game: 324 | enable_sign = True 325 | elif type(matcher) is disable_guess_game: 326 | enable_sign = False 327 | else: 328 | raise ValueError('matcher type error') 329 | 330 | global global_game_data 331 | global_game_data = load_game_data_json(gid) 332 | if arg == "all" or arg == "全部": 333 | for key in global_game_data[gid]['game_enable'].keys(): 334 | global_game_data[gid]['game_enable'][key] = enable_sign 335 | msg = f'已将本群的全部猜歌游戏全部设为{"开启" if enable_sign else "禁用"}' 336 | elif game_alias_map.get(arg): 337 | global_game_data[gid]["game_enable"][arg] = enable_sign 338 | msg = f'已将本群的{game_alias_map.get(arg)}设为{"开启" if enable_sign else "禁用"}' 339 | elif game_alias_map_reverse.get(arg): 340 | global_game_data[gid]["game_enable"][game_alias_map_reverse[arg]] = enable_sign 341 | msg = f'已将本群的{arg}设为{"开启" if enable_sign else "关闭"}' 342 | else: 343 | msg = '您的输入有误,请输入游戏名(比如开字母、谱面猜歌、全部)或其英文(比如listen、cover、all)来进行开启或禁用猜歌游戏' 344 | save_game_data(global_game_data, game_data_path) 345 | #print(global_game_data) 346 | await matcher.finish(msg) 347 | 348 | @top_three.handle() 349 | async def top_three_handler(event: GroupMessageEvent, matcher: Matcher, bot: Bot): 350 | await send_top_three(bot, event.group_id, is_force=True) 351 | 352 | @scheduler.scheduled_job('cron', hour=15, minute=00) 353 | async def _(): 354 | bot: Bot = get_bot() 355 | group_list = await bot.call_api("get_group_list") 356 | for group_info in group_list: 357 | group_id = str(group_info.get("group_id")) 358 | await send_top_three(bot, group_id) 359 | 360 | 361 | @scheduler.scheduled_job('cron', hour=23, minute=57) 362 | async def send_top_three_schedule(): 363 | bot: Bot = get_bot() 364 | group_list = await bot.call_api("get_group_list") 365 | for group_info in group_list: 366 | group_id = str(group_info.get("group_id")) 367 | 368 | # 如果不需要给用户加分(仅展示答对题数与排名),可以将这里的isaddcredit设为False 369 | await send_top_three(bot, group_id, isaddcredit=game_config.everyday_is_add_credits) 370 | 371 | 372 | @scheduler.scheduled_job('cron', hour=00, minute=00) 373 | async def reset_game_data(): 374 | data = load_data(game_data_path) 375 | for gid in data.keys(): 376 | data[gid]['rank'] = {"listen": {}, "open_character": {},"cover": {}, "clue": {}, "chart": {}, "note": {}} 377 | save_game_data(data, game_data_path) 378 | -------------------------------------------------------------------------------- /nonebot_plugin_guess_song/config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from pydantic import BaseModel 3 | from textwrap import dedent 4 | 5 | from nonebot import get_plugin_config 6 | from nonebot.plugin import require 7 | 8 | require("nonebot_plugin_localstore") 9 | 10 | import nonebot_plugin_localstore as store 11 | 12 | 13 | class Config(BaseModel): 14 | character_filter_japenese: bool = True # 开字母游戏是否需要过滤掉含日文字符的歌曲 15 | everyday_is_add_credits: bool = False # 每天给猜歌答对的用户加积分 16 | 17 | game_config = get_plugin_config(Config) 18 | 19 | plugin_data_dir: Path = store.get_plugin_data_dir() 20 | 21 | guess_static_resources_path: Path = plugin_data_dir / "static" 22 | guess_resources_path: Path = plugin_data_dir / "resources" 23 | 24 | music_cover_path: Path = guess_static_resources_path / "mai/cover" 25 | music_info_path: Path = guess_static_resources_path / "music_data.json" 26 | music_alias_path: Path = guess_static_resources_path / "music_alias.json" 27 | font_path: Path = guess_static_resources_path / "SourceHanSansSC-Bold.otf" 28 | 29 | user_info_path: Path = guess_resources_path / "user_info.json" # 基本只用于用户加分 30 | game_data_path: Path = guess_resources_path / "game_data.json" # 需要记录用户猜对的数量以及每个群猜曲绘的配置数据 31 | 32 | game_pic_path: Path = guess_resources_path / "maimai" 33 | music_file_path: Path = guess_resources_path / "music_guo" 34 | chart_file_path: Path = guess_resources_path / "chart_resources" 35 | chart_preload_path: Path = guess_resources_path / "chart_preload" 36 | 37 | 38 | point_per_credit_dict: dict[str, int] = { 39 | "listen": 5, 40 | "open_character": 5, 41 | "cover": 5, 42 | "clue": 3, 43 | "chart": 2, 44 | "note": 2 45 | } 46 | 47 | levelList: list[str] = ['1', '2', '3', '4', '5', '6', '7', '7+', '8', '8+', '9', '9+', '10', '10+', '11', '11+', '12', '12+', '13', '13+', '14', '14+', '15'] 48 | plate_to_version: dict[str, str] = { 49 | '初': 'maimai', 50 | '真': 'maimai PLUS', 51 | '超': 'maimai GreeN', 52 | '檄': 'maimai GreeN PLUS', 53 | '橙': 'maimai ORANGE', 54 | #'暁': 'maimai ORANGE PLUS', 55 | '晓': 'maimai ORANGE PLUS', 56 | '桃': 'maimai PiNK', 57 | #'櫻': 'maimai PiNK PLUS', 58 | '樱': 'maimai PiNK PLUS', 59 | '紫': 'maimai MURASAKi', 60 | #'菫': 'maimai MURASAKi PLUS', 61 | '堇': 'maimai MURASAKi PLUS', 62 | '白': 'maimai MiLK', 63 | '雪': 'MiLK PLUS', 64 | #'輝': 'maimai FiNALE', 65 | '辉': 'maimai FiNALE', 66 | '熊': 'maimai でらっくす', 67 | #'華': 'maimai でらっくす PLUS', 68 | '华': 'maimai でらっくす PLUS', 69 | '爽': 'maimai でらっくす Splash', 70 | '煌': 'maimai でらっくす Splash PLUS', 71 | '宙': 'maimai でらっくす UNiVERSE', 72 | '星': 'maimai でらっくす UNiVERSE PLUS', 73 | '祭': 'maimai でらっくす FESTiVAL', 74 | '祝': 'maimai でらっくす FESTiVAL PLUS', 75 | '双': 'maimai でらっくす BUDDiES' 76 | } 77 | 78 | labelmap = {'华': '熊', '華': '熊', '煌': '爽', '星': '宙', '祝': '祭'} #国服特供 79 | category: dict[str, str] = { 80 | '流行&动漫': 'anime', 81 | '舞萌': 'maimai', 82 | 'niconico & VOCALOID': 'niconico', 83 | '东方Project': 'touhou', 84 | '其他游戏': 'game', 85 | '音击&中二节奏': 'ongeki', 86 | 'POPSアニメ': 'anime', 87 | 'maimai': 'maimai', 88 | 'niconicoボーカロイド': 'niconico', 89 | '東方Project': 'touhou', 90 | 'ゲームバラエティ': 'game', 91 | 'オンゲキCHUNITHM': 'ongeki', 92 | '宴会場': '宴会场' 93 | } 94 | 95 | charterlist: dict[str, list[str]] = { 96 | "0": [ 97 | "-" 98 | ], 99 | "1": [ 100 | "譜面-100号", 101 | "100号", 102 | "譜面-100号とはっぴー", 103 | "谱面100号" 104 | ], 105 | "2": [ 106 | "ニャイン", 107 | "二爷", 108 | "二大爷", 109 | "二先生" 110 | ], 111 | "3": [ 112 | "happy", 113 | "はっぴー", 114 | "譜面-100号とはっぴー", 115 | "はっぴー星人", 116 | "はぴネコ(はっぴー&ぴちネコ)", 117 | "緑風 犬三郎", 118 | "Sukiyaki vs Happy", 119 | "はっぴー respects for 某S氏", 120 | "Jack & はっぴー vs からめる & ぐるん", 121 | "はっぴー & サファ太", 122 | "舞舞10年ズ(チャンとはっぴー)", 123 | "哈皮", 124 | "“H”ack", #happy + jack 125 | "“H”ack underground", 126 | "原田", #ow谱师 = happy 127 | "原田ひろゆき" 128 | ], 129 | "4": [ 130 | "dp", 131 | "チャン@DP皆伝", 132 | "チャン@DP皆伝 vs シチミヘルツ", 133 | "dp皆传", 134 | "DP皆传", 135 | "dq皆传" 136 | ], 137 | "5": [ 138 | "jack", 139 | "Jack", 140 | "“H”ack", 141 | "JAQ", 142 | "7.3Hz+Jack", 143 | "チェシャ猫とハートのジャック", 144 | "“H”ack underground", 145 | "jacK on Phoenix", 146 | "Jack & Licorice Gunjyo", 147 | "Jack & はっぴー vs からめる & ぐるん", 148 | "jacK on Phoenix & -ZONE- SaFaRi", 149 | "Jack vs あまくちジンジャー", 150 | "Garakuta Scramble!" #gdp + 牛奶 = 小鸟游 + jack 151 | ], 152 | "6": [ 153 | "桃子猫", 154 | "ぴちネコ", 155 | "SHICHIMI☆CAT", 156 | "チェシャ猫とハートのジャック", 157 | "はぴネコ(はっぴー&ぴちネコ)", 158 | "アマリリスせんせえ with ぴちネコせんせえ", 159 | "猫", 160 | "ネコトリサーカス団", 161 | "ロシアンブラック" #russianblack = 俄罗斯黑猫 = 桃子猫 162 | ], 163 | "7": [ 164 | "maistar", 165 | "mai-Star", 166 | "迈斯达" 167 | ], 168 | "8": [ 169 | "rion", 170 | "rioN" 171 | ], 172 | "9": [ 173 | "科技厨房", 174 | "Techno Kitchen" 175 | ], 176 | "10": [ 177 | "奉行", 178 | "すきやき奉行", 179 | "Sukiyaki vs Happy" 180 | ], 181 | "11": [ 182 | "合作", #不署名多人统一处理 183 | "合作だよ", 184 | "maimai TEAM", 185 | "みんなでマイマイマー", 186 | "譜面ボーイズからの挑戦状", 187 | "PANDORA BOXXX", 188 | "PANDORA PARADOXXX", 189 | "舞舞10年ズ ~ファイナル~", 190 | "ゲキ*チュウマイ Fumen Team", 191 | "maimai Fumen All-Stars", 192 | "maimai TEAM DX", 193 | "BEYOND THE MEMORiES", 194 | "群星" 195 | ], 196 | "12": [ 197 | "某S氏", 198 | "はっぴー respects for 某S氏" 199 | ], 200 | "13": [ 201 | "monoclock", 202 | "ものくろっく", 203 | "ものくロシェ", 204 | "一ノ瀬 リズ" #马甲 205 | ], 206 | "14": [ 207 | "柠檬", 208 | "じゃこレモン", 209 | "サファ太 vs じゃこレモン", 210 | "僕の檸檬本当上手" 211 | ], 212 | "15": [ 213 | "小鸟游", 214 | "小鳥遊さん", 215 | "Phoenix", 216 | "-ZONE-Phoenix", 217 | "小鳥遊チミ", 218 | "小鳥遊さん fused with Phoenix", 219 | "7.3GHz vs Phoenix", 220 | "jacK on Phoenix", 221 | "The ALiEN vs. Phoenix", 222 | "小鳥遊さん vs 華火職人", 223 | "red phoenix", 224 | "小鳥遊さん×アミノハバキリ", 225 | "Garakuta Scramble!" 226 | ], 227 | "16": [ 228 | "moonstrix", 229 | "Moon Strix" 230 | ], 231 | "17": [ 232 | "玉子豆腐" 233 | ], 234 | "18": [ 235 | "企鹅", 236 | "ロシェ@ペンギン", 237 | "ものくロシェ" 238 | ], 239 | "19": [ 240 | "7.3", 241 | "シチミヘルツ", 242 | "7.3Hz+Jack", 243 | "シチミッピー", 244 | "Safata.Hz", 245 | "7.3GHz", 246 | "七味星人", 247 | "7.3連発華火", 248 | "SHICHIMI☆CAT", 249 | "超七味星人", 250 | "小鳥遊チミ", 251 | "Hz-R.Arrow", 252 | "あまくちヘルツ", 253 | "Safata.GHz", 254 | "7.3GHz vs Phoenix", 255 | "しちみりこりす", 256 | "7.3GHz -Før The Legends-", 257 | "チャン@DP皆伝 vs シチミヘルツ" 258 | ], 259 | "21": [ 260 | "revo", 261 | "Revo@LC" 262 | ], 263 | "22": [ 264 | "沙发太", 265 | "サファ太", 266 | "safaTAmago", 267 | "Safata.Hz", 268 | "Safata.GHz", 269 | "-ZONE- SaFaRi", 270 | "サファ太 vs -ZONE- SaFaRi", 271 | "サファ太 vs じゃこレモン", 272 | "サファ太 vs 翠楼屋", 273 | "jacK on Phoenix & -ZONE- SaFaRi", 274 | "DANCE TIME(サファ太)", 275 | "はっぴー & サファ太", 276 | "さふぁた", 277 | "脆脆鲨", #翠楼屋 + 沙发太 278 | "ボコ太", 279 | "鳩サファzhel", 280 | "Ruby" #squad谱师 = 沙发太 281 | ], 282 | "23": [ 283 | "隅田川星人", 284 | "七味星人", 285 | "隅田川華火大会", 286 | "超七味星人", 287 | "はっぴー星人", 288 | "The ALiEN", 289 | "The ALiEN vs. Phoenix" 290 | ], 291 | "24": [ 292 | "华火职人", 293 | "華火職人", 294 | "隅田川華火大会", 295 | "7.3連発華火", 296 | "“Carpe diem” * HAN∀BI", 297 | "小鳥遊さん vs 華火職人", 298 | "大国奏音" 299 | ], 300 | "25": [ 301 | "labi", 302 | "LabiLabi" 303 | ], 304 | "26": [ 305 | "如月", 306 | "如月 ゆかり" 307 | ], 308 | "27": [ 309 | "畳返し" 310 | ], 311 | "28": [ 312 | "翠楼屋", 313 | "翡翠マナ", 314 | "作譜:翠楼屋", 315 | "KOP3rd with 翡翠マナ", 316 | "Redarrow VS 翠楼屋", 317 | "翠楼屋 vs あまくちジンジャー", 318 | "サファ太 vs 翠楼屋", 319 | "翡翠マナ -Memoir-", 320 | "脆脆鲨", 321 | "Ruby" 322 | ], 323 | "29": [ 324 | "鸽子", 325 | "鳩ホルダー", 326 | "鳩ホルダー & Luxizhel", 327 | "鳩サファzhel", 328 | "鸠", 329 | "九鸟" 330 | ], 331 | "30": [ 332 | "莉莉丝", 333 | "アマリリス", 334 | "アマリリスせんせえ", 335 | "アマリリスせんせえ with ぴちネコせんせえ" 336 | ], 337 | "31": [ 338 | "redarrow", 339 | "Redarrow", 340 | "Hz-R.Arrow", 341 | "red phoenix", 342 | "Redarrow VS 翠楼屋", 343 | "红箭" 344 | ], 345 | "32": [ 346 | "泸溪河", 347 | "Luxizhel", 348 | "鳩ホルダー & Luxizhel", 349 | "桃酥", 350 | "鳩サファzhel" 351 | ], 352 | "33": [ 353 | "amano", 354 | "アミノハバキリ", 355 | "小鳥遊さん×アミノハバキリ" 356 | ], 357 | "34": [ 358 | "甜口姜", 359 | "あまくちジンジャー", 360 | "あまくちヘルツ", 361 | "翠楼屋 vs あまくちジンジャー", 362 | "Jack vs あまくちジンジャー" 363 | ], 364 | "35": [ 365 | "カマボコ君", 366 | "ボコ太" 367 | ], 368 | "36": [ 369 | "rintarosoma", 370 | "rintaro soma" 371 | ], 372 | "37": [ 373 | "味增", 374 | "みそかつ侍" 375 | ], 376 | "38": [ 377 | "佑" 378 | ], 379 | "39": [ 380 | "群青リコリス" 381 | ], 382 | "41": [ 383 | "しろいろ" 384 | ], 385 | "42": [ 386 | "ミニミライト" 387 | ], 388 | "43": [ 389 | "メロンポップ", 390 | "ずんだポップ" 391 | ] 392 | } 393 | 394 | guess_help_message = dedent(''' 395 | 猜歌游戏介绍: 396 | 猜曲绘:根据曲绘猜歌 397 | 听歌猜曲:根据歌曲猜歌 398 | 谱面猜歌:根据谱面猜歌 399 | 线索猜歌:根据歌曲信息猜歌 400 | 开字母:每次可选择揭开一个字母(将歌名中该字母的位置显现出来),或者选择根据已有信息猜一首歌。从支离破碎的信息中,破解歌曲的名称。 401 | note音猜歌:根据谱面的note音来猜歌 402 | 随机猜歌:从猜曲绘、线索猜歌、听歌猜曲、谱面猜歌中随机选择一种猜歌方式 403 | 404 | 游戏命令(直接输左侧的任一命令): 405 | /开字母 /猜曲绘 /线索猜歌 /听歌猜曲 /谱面猜歌 /note音猜歌 /随机猜歌 - 开始游戏 406 | /连续猜曲绘 /连续听歌猜曲 /连续线索猜歌 /连续谱面猜歌 /连续note音猜歌 /连续随机猜歌 - 开始连续游戏 407 | 开<字母> - 开字母游戏中用于开对应字母 408 | (猜歌/开歌) <歌名/别名/id> - 猜歌 409 | 不玩了 - 结束本轮游戏 410 | 停止 - 停止连续游戏 411 | 强制停止 - 强制停止所有游戏(在遇到非预期情况时,可以使用该指令强行恢复) 412 | 413 | 其他命令: 414 | /猜歌帮助 - 查看帮助 415 | /前三 - 查看今日截至当前的答对数量的玩家排名 416 | /查看谱师 - 查看可用谱师 417 | 418 | 管理员命令: 419 | /猜曲绘配置 <参数> - 配置猜曲绘游戏难度,详细帮助可直接使用此命令查询 420 | /检查谱面完整性 - 检查谱面猜歌所需的谱面源文件的完整性 421 | /检查歌曲文件完整性 - 检查听歌猜曲所需的音乐源文件的完整性 422 | /开启猜歌 <游戏名/all> - 开启某类或全部猜歌游戏 423 | /关闭猜歌 <游戏名/all> - 关闭某类或全部猜歌游戏 424 | 425 | 说明: 426 | 一个群内只能同时玩一个游戏。 427 | 玩游戏可获得积分,不同游戏获得的积分不同(谱面猜歌与note音猜歌每2首1积分,线索猜歌每3首1积分,其他每5首1积分)。 428 | 429 | 开始游戏、开始连续游戏的命令都可附加参数,命令后面接若干个参数,每个参数用空格隔开。(可以看最下方的示例参照使用) 430 | 可用参数有: 431 | 版本<版本名> - 可用版本有:初、真、超、檄、橙、晓、桃、樱、紫、堇、白、雪、辉、熊、华、爽、煌、宙、星、祭、祝、双。 432 | 分区<分区名> - 可用分区有:anime、maimai、niconico、touhou、game、ongeki。 433 | 谱师<谱师名> - 可用谱师可用“/查看谱师”命令查询。 434 | 等级<等级> - 只考虑歌曲的紫谱及白谱(如有)。可用等级为1-15。 435 | 定数<定数> - 只考虑歌曲的紫谱及白谱(如有)。可用定数为1.0-15.0。 436 | 新 旧 sd dx - 新为在b35的歌;旧为在b15的歌;sd为标准谱;dx为dx谱。 437 | 同类参数取并集,不同类参数取交集。 438 | 所有参数都可以添加前缀“-”表示排除该条件。 439 | 版本、等级、定数可在自选参数前添加 >= > <= < 中之一表示范围选择。 440 | 附上版本参数的正则表达式:'^(-?)版本([<>]?)(\\=?)(.)$'。 441 | 442 | 示例: 443 | /开字母 444 | /猜曲绘 定数14.0 445 | /连续随机猜歌 谱师沙发太 分区maimai -版本<=檄 等级>12+ (指定了谱师和分区,版本不包含初真超檄,等级大于12+) 446 | ''') 447 | 448 | superuser_help_message = dedent(''' 449 | 猜曲绘配置指令可用参数有: 450 | cut = [0,1) 控制切片大小的比例,默认为0.5 451 | gauss = [0,无穷) 控制模糊程度,默认为10 452 | shuffle = [0,1) 控制打乱方块大小的比例,建议取1/n,默认为0.1 453 | gray = [0,1] 控制灰度程度,默认为0.8 454 | transpose = 0 or 1 是否开启旋转及翻转,默认为0 455 | 每次游戏从前三属性中三选一,再分别随机选择是否应用后两个效果。 456 | 因此需确保前三属性不能同时为0。 457 | --- 458 | 示例: 459 | /猜曲绘配置 cut=0.5 gray=0.8 460 | ''') -------------------------------------------------------------------------------- /nonebot_plugin_guess_song/libraries/__init__.py: -------------------------------------------------------------------------------- 1 | from .music_model import * 2 | from .utils import * 3 | from .guess_cover import * 4 | from .guess_character import * 5 | from .guess_song_listen import * 6 | from .guess_song_clue import * 7 | from .guess_song_chart import * 8 | from .guess_song_note import * -------------------------------------------------------------------------------- /nonebot_plugin_guess_song/libraries/guess_character.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from .utils import * 4 | from .music_model import gameplay_list, game_alias_map, filter_list, alias_dict, total_list 5 | 6 | from nonebot import on_command, on_startswith 7 | from nonebot.matcher import Matcher 8 | from nonebot.adapters.onebot.v11 import GroupMessageEvent 9 | from nonebot.params import Startswith, CommandArg 10 | 11 | 12 | guess_character_request = on_command("开字母", aliases = {"猜字母"}, priority=5) 13 | guess_open = on_startswith("开", priority=5) 14 | 15 | 16 | def open_character_message(group_id, first = False, early_stop = False): 17 | 18 | character_info = gameplay_list.get(group_id).get("open_character") 19 | song_info_list = character_info.get("info") 20 | guessed_character = character_info.get("guessed") 21 | params = character_info.get("params") 22 | 23 | message = "[猜你字母bot 开字母]\n" 24 | if len(params): 25 | message += f"本次开字母范围:{', '.join(params)}\n" 26 | total_success = 0 27 | 28 | # 展示各歌曲的开字母情况 29 | for i in range(len(song_info_list)): 30 | if song_info_list[i].get("state"): 31 | message += f"✅{i+1}. {song_info_list[i].get('title')}\n" 32 | total_success += 1 33 | elif early_stop: 34 | message += f"❌{i+1}. {song_info_list[i].get('title')}\n" 35 | else: 36 | message += f"❓{i+1}. {song_info_list[i].get('guessed')}\n" 37 | 38 | message += ( 39 | f"已开字符:{', '.join(guessed_character)}\n" 40 | ) 41 | 42 | if total_success == len(song_info_list): 43 | start_time = character_info.get("start_time") 44 | current_time = datetime.now() 45 | time_diff = current_time - start_time 46 | total_seconds = int(time_diff.total_seconds()) 47 | message += f"全部猜对啦!本次开字母用时{total_seconds}秒\n" 48 | gameplay_list.pop(group_id) 49 | 50 | if early_stop: 51 | message += f"本次开字母只猜对了{total_success}首歌,要再接再厉哦!本次开字母结束\n" 52 | gameplay_list.pop(group_id) 53 | 54 | if first: 55 | message += ( 56 | "发送 “开x” 揭开对应的字母(或字符)\n" 57 | "发送 “开歌xxx”,来提交您认为在答案中的曲目(无需带序号)\n" 58 | "发送 “不玩了” 退出开字母并公布答案" 59 | ) 60 | 61 | return message 62 | 63 | 64 | def open_character_reply_message(success_guess): 65 | message = "" 66 | if len(success_guess) == 0: 67 | message += "本次没有猜对任何曲目哦。" 68 | else: 69 | message += "你猜对了" 70 | for i in range(len(success_guess)): 71 | message += success_guess[i] 72 | if i != len(success_guess)-1: 73 | message += ", " 74 | message += "\n" 75 | return MessageSegment("text", {"text": message}) 76 | 77 | 78 | def open_character_rank_message(group_id): 79 | top_open_character = get_top_three(group_id, "open_character") 80 | if top_open_character: 81 | msg = "今天的前三名开字母高手:\n" 82 | for rank, (user_id, count) in enumerate(top_open_character, 1): 83 | msg += f"{rank}. {MessageSegment.at(user_id)} 开出了{count}首歌!\n" 84 | msg += "一堆maip。。。" 85 | return msg 86 | 87 | 88 | @guess_character_request.handle() 89 | async def guess_request_handler(matcher: Matcher, event: GroupMessageEvent, args: Message = CommandArg()): 90 | # 启动游戏 91 | if len(total_list.music_list) == 0: 92 | await matcher.finish("本插件还没有配置好static资源噢,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!") 93 | group_id = str(event.group_id) 94 | game_name = "open_character" 95 | if check_game_disable(group_id, game_name): 96 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!") 97 | params = args.extract_plain_text().strip().split() 98 | await isplayingcheck(group_id, matcher) 99 | if not (random_music := filter_random(filter_list, params, 10)): 100 | await matcher.finish(fault_tips, reply_message=True) 101 | start_time = datetime.now() 102 | info_list = [] 103 | for music in random_music: 104 | guessed = ''.join(['?' if char != ' ' else ' ' for char in music.title]) 105 | info_list.append({"title": music.title, "guessed": guessed, "state": False}) 106 | gameplay_list[group_id] = {} 107 | gameplay_list[group_id]["open_character"] = {"info": info_list, "guessed": [], "start_time": start_time, "params": params} 108 | await matcher.finish(open_character_message(group_id, first = True)) 109 | 110 | 111 | @guess_open.handle() 112 | async def guess_open_handler(matcher: Matcher, event: GroupMessageEvent, start: str = Startswith()): 113 | # 开单个字母 114 | group_id = str(event.group_id) 115 | if gameplay_list.get(group_id) and gameplay_list.get(group_id).get("open_character") is not None: 116 | character_info = gameplay_list.get(group_id).get("open_character") 117 | character = event.get_plaintext().lower()[len(start):].strip() 118 | if len(character) != 1: 119 | return 120 | if character != "": 121 | character = character.lower() 122 | if character_info.get("guessed").count(character) != 0: 123 | await matcher.finish("这个字母已经开过了哦,换一个字母吧", reply_message=True) 124 | character_info.get("guessed").append(character) 125 | for music in character_info.get("info"): 126 | title = music.get("title") 127 | guessed = list(music.get("guessed")) 128 | for i in range(len(title)): 129 | if title[i].lower() == character: 130 | guessed[i] = title[i] 131 | music["title"] = title 132 | music["guessed"] = ''.join(guessed) 133 | 134 | await matcher.finish(open_character_message(group_id)) 135 | else: 136 | await matcher.finish("无效指令,请使用格式:开x(x只能是一个字符)", reply_message=True) 137 | 138 | 139 | async def character_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag): 140 | # 开歌的处理函数 141 | character_info = gameplay_list.get(group_id).get("open_character") 142 | music_candidates = alias_dict.get(song_name) 143 | if music_candidates is None: 144 | if ignore_tag: 145 | return 146 | await matcher.finish("没有找到这样的乐曲。请输入正确的名称或别名") 147 | 148 | success_guess = [] 149 | if len(music_candidates) < 20: 150 | for music_index in music_candidates: 151 | music = total_list.music_list[music_index] 152 | for info in character_info.get('info'): 153 | if info.get('title') == music.title and info.get("state") == False: 154 | blindly_guess_check = True 155 | for char in info['guessed']: 156 | if char != ' ' and char != '?': 157 | blindly_guess_check = False 158 | break 159 | if blindly_guess_check: 160 | # 如果盲狙中了,就随机发送“这么难他都会”的图 161 | probability = random.random() 162 | if probability < 0.4: 163 | try: 164 | pic_path: Path = game_pic_path / "so_hard.jpg" 165 | await matcher.send(MessageSegment.image(f"file://{pic_path}")) 166 | except: 167 | await matcher.send("这么难他都会") 168 | success_guess.append(music.title) 169 | info['guessed'] = music.title 170 | info['state'] = True 171 | record_game_success(user_id=user_id, group_id=group_id, game_type="open_character") 172 | if len(success_guess) == 0 and ignore_tag: 173 | return 174 | message = open_character_reply_message(success_guess) 175 | message += open_character_message(group_id) 176 | await matcher.finish(message, reply_message=True) 177 | 178 | 179 | -------------------------------------------------------------------------------- /nonebot_plugin_guess_song/libraries/guess_cover.py: -------------------------------------------------------------------------------- 1 | import random 2 | import asyncio 3 | from typing import Tuple 4 | from PIL import Image, ImageFilter, ImageEnhance 5 | 6 | from .utils import * 7 | from .music_model import gameplay_list, alias_dict, total_list, game_alias_map, continuous_stop 8 | 9 | from nonebot import on_fullmatch, on_command 10 | from nonebot.matcher import Matcher 11 | from nonebot.adapters.onebot.v11 import GroupMessageEvent, GROUP_ADMIN, GROUP_OWNER 12 | from nonebot.permission import SUPERUSER 13 | from nonebot.params import CommandArg 14 | 15 | 16 | guess_cover_config_dict = {} 17 | 18 | continuous_guess_cover = on_command('连续猜曲绘', priority=5) 19 | guess_cover = on_command('猜曲绘', priority=5) 20 | guess_cover_config = on_command('猜曲绘配置', permission=SUPERUSER | GROUP_ADMIN | GROUP_OWNER, priority=5) 21 | 22 | 23 | def apply_gray(im: Image.Image, gray) -> Image.Image: 24 | gray_im = im.convert('L') 25 | enhancer = ImageEnhance.Brightness(gray_im) 26 | semi_gray = enhancer.enhance(0.5) 27 | result = Image.blend(im.convert('RGBA'), semi_gray.convert('RGBA'), alpha=gray) 28 | return result 29 | 30 | def apply_gauss(im: Image.Image, gauss) -> Image.Image: 31 | return im.filter(ImageFilter.GaussianBlur(radius = gauss)) 32 | 33 | def apply_cut(im: Image.Image, cut) -> Image.Image: 34 | w, h = im.size 35 | w2, h2 = int(w*cut), int(h*cut) 36 | l, u = random.randrange(0, int(w-w2)), random.randrange(0, int(h-h2)) 37 | return im.crop((l, u, l + w2, u + h2)) 38 | 39 | def apply_transpose(im: Image.Image) -> Image.Image: 40 | angle = random.randint(0, 3) * 90 41 | im = im.rotate(angle) 42 | flip = random.randint(0, 2) 43 | if flip == 1: 44 | im = im.transpose(Image.FLIP_LEFT_RIGHT) 45 | elif flip == 2: 46 | im = im.transpose(Image.FLIP_TOP_BOTTOM) 47 | return im 48 | 49 | def apply_shuffle(im: Image.Image, shuffle) -> Image.Image: 50 | """拆分成方块随机排列""" 51 | w, h = im.size 52 | block_size = int(w * shuffle) 53 | blocks = [] 54 | for i in range(0, w, block_size): 55 | for j in range(0, h, block_size): 56 | block = im.crop((i, j, i + block_size, j + block_size)) 57 | blocks.append(block) 58 | random.shuffle(blocks) 59 | shuffled = Image.new("RGB", im.size) 60 | idx = 0 61 | for i in range(0, w, block_size): 62 | for j in range(0, h, block_size): 63 | shuffled.paste(blocks[idx], (i, j)) 64 | idx += 1 65 | return shuffled 66 | 67 | 68 | vital_apply = { 69 | 'gauss': '模糊', 70 | 'cut': '裁切', 71 | 'shuffle': '打乱', 72 | } 73 | 74 | 75 | async def pic(path: str, gid: str) -> Tuple[Image.Image, str]: 76 | im = Image.open(path) 77 | data = load_game_data_json(gid) 78 | args = data[gid]['config'] 79 | sample_pool = [name for name,value in args.items() if name in vital_apply.keys() and value != 0] 80 | pattern = random.sample(sample_pool, 1) 81 | if pattern[0] == 'gauss': 82 | im = apply_gauss(im, args['gauss']) 83 | elif pattern[0] == 'cut': 84 | im = apply_cut(im, args['cut']) 85 | elif pattern[0] == 'shuffle': 86 | im = apply_shuffle(im, args['shuffle']) 87 | 88 | if args['gray'] and random.randint(0,1): 89 | im = apply_gray(im, float(args['gray'])) 90 | if args['transpose'] and random.randint(0,1): 91 | im = apply_transpose(im) 92 | return im, vital_apply[pattern[0]] 93 | 94 | 95 | def cover_rank_message(group_id): 96 | top_guess_cover = get_top_three(group_id, "cover") 97 | if top_guess_cover: 98 | msg = "今天的前三名猜曲绘王:\n" 99 | for rank, (user_id, count) in enumerate(top_guess_cover, 1): 100 | msg += f"{rank}. {MessageSegment.at(user_id)} 猜对了{count}首歌!\n" 101 | msg += "蓝的盆。。。" 102 | return msg 103 | 104 | 105 | async def cover_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag): 106 | pic_info = gameplay_list.get(group_id).get("cover") 107 | 108 | music_candidates = alias_dict.get(song_name) 109 | if music_candidates is None: 110 | if ignore_tag: 111 | return 112 | await matcher.finish("没有找到这样的乐曲。请输入正确的名称或别名", reply_message=True) 113 | 114 | if len(music_candidates) < 20: 115 | for music_index in music_candidates: 116 | music = total_list.music_list[music_index] 117 | if pic_info.title == music.title: 118 | gameplay_list.pop(group_id) 119 | record_game_success(user_id=user_id, group_id=group_id, game_type="cover") 120 | answer = MessageSegment.text(f'猜对啦!答案是:\n')+song_txt(pic_info) 121 | await matcher.finish(answer, reply_message=True) 122 | if not ignore_tag: 123 | await matcher.finish("你猜的答案不对噢,再仔细看一下吧!", reply_message=True) 124 | 125 | 126 | @continuous_guess_cover.handle() 127 | async def continuous_guess_cover_handler(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()): 128 | if len(total_list.music_list) == 0: 129 | await matcher.finish("本插件还没有配置好static资源噢,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!") 130 | gid = str(event.group_id) 131 | game_name = "cover" 132 | if check_game_disable(gid, game_name): 133 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!") 134 | params = args.extract_plain_text().strip().split() 135 | await isplayingcheck(gid, matcher) 136 | if not filter_random(total_list.music_list, params, 1): 137 | await matcher.finish(fault_tips, reply_message=True) 138 | await matcher.send('连续猜曲绘已开启,发送\"停止\"以结束') 139 | continuous_stop[gid] = 1 140 | while continuous_stop.get(gid): 141 | if gameplay_list.get(gid) is None: 142 | await guess_cover_handler(gid, matcher, params) 143 | if continuous_stop[gid] > 3: 144 | continuous_stop.pop(gid) 145 | await matcher.finish('没人猜了? 那我下班了。') 146 | await asyncio.sleep(1) 147 | 148 | 149 | @guess_cover.handle() 150 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()): 151 | if len(total_list.music_list) == 0: 152 | await matcher.finish("本插件还没有配置好static资源噢,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!") 153 | gid = str(event.group_id) 154 | game_name = "cover" 155 | if check_game_disable(gid, game_name): 156 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!") 157 | params = args.extract_plain_text().strip().split() 158 | await isplayingcheck(gid, matcher) 159 | await guess_cover_handler(gid, matcher, params) 160 | 161 | async def guess_cover_handler(gid, matcher: Matcher, args): 162 | if _ := filter_random(total_list.music_list, args, 1): 163 | random_music = _[0] 164 | else: 165 | await matcher.finish(fault_tips, reply_message=True) 166 | pic_path = music_cover_path / (get_cover_len5_id(random_music.id) + ".png") 167 | draw, pictype = await pic(pic_path, str(gid)) 168 | gameplay_list[gid] = {} 169 | gameplay_list[gid]["cover"] = random_music 170 | 171 | msg = MessageSegment.text(f'以下{pictype}图片是哪首歌的曲绘:\n') 172 | msg += MessageSegment.image(image_to_base64(draw)) 173 | msg += MessageSegment.text('请在30s内输入答案') 174 | if len(args): 175 | msg += MessageSegment.text(f"\n本次猜曲绘范围:{', '.join(args)}") 176 | await matcher.send(msg) 177 | for _ in range(30): 178 | await asyncio.sleep(1) 179 | if gameplay_list.get(gid) is None or not gameplay_list[gid].get("cover") or gameplay_list[gid].get("cover") != random_music: 180 | if continuous_stop.get(gid): 181 | continuous_stop[gid] = 1 182 | return 183 | gameplay_list.pop(gid) 184 | answer = MessageSegment.text(f'答案是:\n') + song_txt(random_music) 185 | await matcher.send(answer) 186 | if continuous_stop.get(gid): 187 | continuous_stop[gid] += 1 188 | 189 | 190 | @guess_cover_config.handle() 191 | async def guess_cover_config_handler(event: GroupMessageEvent, matcher: Matcher, arg: Message = CommandArg()): 192 | gid = str(event.group_id) 193 | mes = arg.extract_plain_text().split() 194 | if len(mes) == 0: 195 | await matcher.finish(MessageSegment.image(to_bytes_io(superuser_help_message)), reply_message=True) 196 | data = load_game_data_json(gid) 197 | config = data[gid]['config'] 198 | try: 199 | for update in mes: 200 | name, value = update.split('=') 201 | if name == 'cut': 202 | value = float(value) 203 | if value < 0 or value >= 1: 204 | raise ValueError 205 | elif name == 'gauss': 206 | value = int(value) 207 | if value < 0: 208 | raise ValueError 209 | elif name == 'gray': 210 | value = float(value) 211 | if value < 0 or value > 1: 212 | raise ValueError 213 | elif name == 'transpose': 214 | value = bool(int(value)) 215 | elif name == 'shuffle': 216 | value = float(value) 217 | if value < 0 or value >= 1: 218 | raise ValueError 219 | else: 220 | raise ValueError 221 | config[name] = value 222 | except ValueError: 223 | await matcher.finish('参数错误' + MessageSegment.image(to_bytes_io(superuser_help_message)), reply_message=True) 224 | if config['cut'] == 0 and config['gauss'] == 0 and config['shuffle'] == 0: 225 | config['cut'] = 1 226 | save_game_data(data, game_data_path) 227 | await matcher.finish(f'更新配置完成:\n{config}') -------------------------------------------------------------------------------- /nonebot_plugin_guess_song/libraries/guess_song_chart.py: -------------------------------------------------------------------------------- 1 | import re 2 | import copy 3 | import random 4 | import shutil 5 | import asyncio 6 | import subprocess 7 | 8 | from .utils import * 9 | from .music_model import gameplay_list, game_alias_map, alias_dict, total_list, continuous_stop 10 | 11 | from nonebot import on_fullmatch, on_command 12 | from nonebot.plugin import require 13 | from nonebot.matcher import Matcher 14 | from nonebot.params import CommandArg 15 | from nonebot.permission import SUPERUSER 16 | from nonebot.adapters.onebot.v11 import Message, GroupMessageEvent 17 | 18 | scheduler = require('nonebot_plugin_apscheduler') 19 | 20 | from nonebot_plugin_apscheduler import scheduler 21 | 22 | config = get_plugin_config(Config) 23 | 24 | id_mp3_file_map = {} 25 | id_mp4_file_map = {} 26 | id_music_map = {} 27 | groupID_params_map: Dict[int, str] = {} # 这个map只在带参数的连续谱面猜歌下需要使用 28 | chart_total_list: List[Music] = [] # 为谱面猜歌特制的total_list,包含紫白谱的music(两份),紫谱music去掉白谱的内容(定数等),白谱music去掉紫谱内容并顶到紫谱位置 29 | loading_clip_list = [] 30 | groupID_folder2delete_timestamp_list: List = {} # 在猜完之后需要删除参数文件夹,所以需记录下来(如果直接删除可能会导致race condition) 31 | CLIP_DURATION = 30 32 | GUESS_TIME = 90 33 | PRELOAD_CHECK_TIME = 40 # 这个时间请根据服务器性能来设置,建议设置为制作一套谱面视频的时间+10秒左右 34 | PRELOAD_CNT = 10 35 | PARAM_PRELOAD_CHECK_TIME = 35 36 | PARAM_PRELOAD_CNT = 3 37 | TIME_TO_DELETE = 120 38 | 39 | general_charts_pool = [] 40 | charts_save_list = [] # 用于存住当前正在游玩的answer文件,防止自检时删掉(因为它的question已经被删掉了,所以他可能会被当成单身谱面而被删掉) 41 | param_charts_pool: Dict[str, List] = {} 42 | 43 | guess_chart = on_command("看谱猜歌", aliases={"谱面猜歌"}, priority=5) 44 | continuous_guess_chart = on_command('连续谱面猜歌', priority=5) 45 | check_chart_file_completeness = on_command("检查谱面完整性", permission=SUPERUSER, priority=5) 46 | 47 | 48 | def cut_video(input_path, output_path, start_time): 49 | input_path = str(input_path) 50 | output_path = str(output_path) 51 | command = [ 52 | "ffmpeg", 53 | "-ss", seconds_to_hms(start_time), 54 | "-i", input_path, 55 | "-strict", "experimental", 56 | "-c:v", "libx264", # Copy the video stream without re-encoding 57 | "-crf", "23", 58 | "-ar", "44100", # 这个一定要加,因为原本是11025hz的采样率,发到qq有部分用户会出现卡顿的情况 59 | "-t", str(CLIP_DURATION), 60 | output_path 61 | ] 62 | try: 63 | logging.info("Cutting video...") 64 | subprocess.run(command, check=True) 65 | logging.info(f"Cutting completed. File saved as '{output_path}'.") 66 | except subprocess.CalledProcessError as e: 67 | logging.error(f"Error during cutting: {e}", exc_info=True) 68 | except Exception as ex: 69 | logging.error(f"An unexpected error occurred: {ex}", exc_info=True) 70 | 71 | 72 | def random_video_clip(input_path, duration, music_id, output_folder): 73 | if not os.path.isfile(input_path): 74 | raise FileNotFoundError(f"输入文件不存在: {input_path}") 75 | os.makedirs(output_folder, exist_ok=True) 76 | 77 | video_duration = get_video_duration(input_path) 78 | if duration > video_duration - 15: 79 | raise ValueError(f"截取时长不能超过视频总时长减去{15 + duration}秒") 80 | 81 | # 前5秒是片头,会露出曲名,后10秒是片尾,只会有无用的all perfect画面 82 | start_time = random.uniform(5, video_duration - duration - 10) 83 | # 需要注意,output_folder需要为绝对路径,才能准确地找到对应的文件 84 | output_path = os.path.join(output_folder, f"{music_id}_{int(start_time)}_clip.mp4") 85 | 86 | if os.path.isfile(output_path): 87 | raise Exception(f"生成了重复的文件") 88 | 89 | cut_video(input_path, output_path, start_time) 90 | 91 | return start_time, output_path 92 | 93 | 94 | async def make_answer_video(video_file, audio_file, music_id, output_folder, start_time, duration): 95 | if not os.path.exists(video_file): 96 | logging.error(f"Error: Video file '{video_file}' does not exist.", exc_info=True) 97 | return 98 | if not os.path.exists(audio_file): 99 | logging.error(f"Error: Audio file '{audio_file}' does not exist.", exc_info=True) 100 | return 101 | 102 | os.makedirs(output_folder, exist_ok=True) 103 | answer_file = os.path.join(output_folder, f"{music_id}_answer.mp4") 104 | answer_clip_path = os.path.join(output_folder, f"{music_id}_{int(start_time)}_answer_clip.mp4") 105 | 106 | loading_clip_list.append(answer_clip_path) # 保护一下(防止还没准备完毕就被发出了) 107 | 108 | # 使用异步线程来执行ffmpeg命令 109 | await asyncio.to_thread(merge_video_and_sound, video_file, audio_file, answer_file) 110 | # 使用异步线程来处理视频 111 | await asyncio.to_thread(cut_video, answer_file, answer_clip_path, start_time) 112 | 113 | # 删除文件(可以保留同步操作) 114 | os.remove(answer_file) # 删掉完整的答案文件(只发送前面猜的片段) 115 | loading_clip_list.remove(answer_clip_path) # 解除保护 116 | 117 | return answer_clip_path 118 | 119 | 120 | # 在后台线程中运行ffmpeg命令 121 | def merge_video_and_sound(video_file, audio_file, answer_file): 122 | command = [ 123 | "ffmpeg", 124 | "-i", video_file, 125 | "-i", audio_file, 126 | "-c:v", "copy", # Copy the video stream without re-encoding 127 | "-c:a", "aac", # Encode the audio stream using AAC 128 | "-strict", "experimental", # Allow experimental features 129 | "-map", "0:v:0", # Use the video stream from the first input 130 | "-map", "1:a:0", # Use the audio stream from the second input 131 | answer_file 132 | ] 133 | try: 134 | logging.info("Merging video and audio...") 135 | subprocess.run(command, check=True) # 合成视频文件 136 | logging.info(f"Merging completed. File saved as '{answer_file}'.") 137 | except subprocess.CalledProcessError as e: 138 | logging.error(f"Error during merging: {e}", exc_info=True) 139 | except Exception as ex: 140 | logging.error(f"An unexpected error occurred: {ex}", exc_info=True) 141 | 142 | 143 | def chart_rank_message(group_id): 144 | top_chart = get_top_three(group_id, "chart") 145 | if top_chart: 146 | msg = "今天的前三名谱面猜歌王:\n" 147 | for rank, (user_id, count) in enumerate(top_chart, 1): 148 | msg += f"{rank}. {MessageSegment.at(user_id)} 猜对了{count}首歌!\n" 149 | msg += "记忆大神啊。。。" 150 | return msg 151 | 152 | 153 | async def chart_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag): 154 | '''开歌处理函数''' 155 | answer_clip_path = gameplay_list[group_id].get("chart") 156 | 157 | random_music_id, start_time = split_id_from_path(answer_clip_path) 158 | is_remaster = False 159 | if int(random_music_id) > 500000: 160 | # 是白谱,需要找回原始music对象 161 | random_music_id = str(int(random_music_id) % 500000) 162 | is_remaster = True 163 | random_music = total_list.by_id(random_music_id) 164 | 165 | music_candidates = alias_dict.get(song_name) 166 | if music_candidates is None: 167 | if ignore_tag: 168 | return 169 | await matcher.finish("没有找到这样的乐曲。请输入正确的名称或别名", reply_message=True) 170 | 171 | if len(music_candidates) < 20: 172 | for music_index in music_candidates: 173 | music = total_list.music_list[music_index] 174 | if random_music.id == music.id: 175 | record_game_success(user_id=user_id, group_id=group_id, game_type="chart") 176 | gameplay_list.pop(group_id) # 需要先pop,不然的话发了答案之后还能猜 177 | charts_save_list.append(answer_clip_path) # 但是必须先把它保护住,否则有可能发到一半被删掉 178 | reply_message = MessageSegment.text("恭喜你猜对啦!答案就是:\n") + song_txt(random_music, is_remaster) + "\n对应的原片段如下:" 179 | await matcher.send(reply_message, reply_message=True) 180 | if answer_clip_path in loading_clip_list: 181 | for i in range(31): 182 | await asyncio.sleep(1) 183 | if answer_clip_path not in loading_clip_list: 184 | break 185 | if i == 30: 186 | await matcher.finish(f"答案文件可能坏掉了,这个谱的开始时间是{start_time}秒,如果感兴趣可以自己去搜索噢", reply_message=True) 187 | await matcher.send(MessageSegment.video(f"file://{answer_clip_path}")) 188 | os.remove(answer_clip_path) 189 | charts_save_list.remove(answer_clip_path) # 发完了就可以解除保护了 190 | return 191 | if not ignore_tag: 192 | await matcher.finish("你猜的答案不对噢,再仔细看一下吧!", reply_message=True) 193 | 194 | 195 | @guess_chart.handle() 196 | async def guess_chart_request(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()): 197 | if len(chart_total_list) == 0: 198 | await matcher.finish("文件夹没有下载任何谱面视频资源,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!") 199 | group_id = str(event.group_id) 200 | game_name = "chart" 201 | if check_game_disable(group_id, game_name): 202 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!") 203 | await isplayingcheck(group_id, matcher) 204 | params = args.extract_plain_text().strip().split() 205 | if len(params) == 0: 206 | # 没有参数的谱面猜歌 207 | if len(general_charts_pool) == 0: 208 | await matcher.finish("当前还没有准备好的谱面噢,请过15秒再尝试一下吧!如果反复出现该问题,请联系bot主扩大preload容量!", reply_message=True) 209 | (question_clip_path, answer_clip_path) = general_charts_pool.pop(random.randint(0, len(general_charts_pool) - 1)) 210 | await guess_chart_handler(group_id, matcher, question_clip_path, answer_clip_path, params) 211 | else: 212 | gameplay_list[group_id] = {} 213 | gameplay_list[group_id]["chart"] = {} # 先占住坑,因为制作视频的时间很长,防止在此期间有其它猜歌请求 214 | random_music = filter_random(chart_total_list, params, 1) 215 | if not random_music: 216 | gameplay_list.pop(group_id) 217 | await matcher.finish(fault_tips, reply_message=True) 218 | await matcher.send("使用带参数的谱面猜歌需要等待15秒左右噢!带参数游玩推荐使用“连续谱面猜歌”,等待时间会缩短很多!", reply_message=True) 219 | random_music = random_music[0] 220 | 221 | sub_path_name = f"{str(event.group_id)}_{'_'.join(params)}" 222 | output_folder = chart_preload_path / sub_path_name 223 | random_music_id = random_music.id 224 | music_mp4_file = id_mp4_file_map[random_music_id] 225 | music_mp3_file = id_mp3_file_map[random_music_id] 226 | 227 | # 使用异步线程才能保证其他任务不被影响 228 | start_time, clip_path = await asyncio.to_thread(random_video_clip, music_mp4_file, CLIP_DURATION, random_music_id, output_folder) 229 | make_answer_task = asyncio.create_task(make_answer_video(music_mp4_file, music_mp3_file, random_music_id, output_folder, start_time, CLIP_DURATION)) 230 | answer_clip_path = os.path.join(output_folder, f"{random_music_id}_{int(start_time)}_answer_clip.mp4") 231 | await guess_chart_handler(group_id, matcher, clip_path, answer_clip_path, params) 232 | 233 | # 带参数的谱面猜歌要自己管好自己的文件 234 | now = datetime.now() 235 | now_timestamp = int(now.timestamp()) 236 | groupID_folder2delete_timestamp_list.append((group_id, chart_preload_path / sub_path_name, now_timestamp)) 237 | 238 | 239 | @continuous_guess_chart.handle() 240 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()): 241 | if len(chart_total_list) == 0: 242 | await matcher.finish("文件夹没有下载任何谱面视频资源,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!") 243 | group_id = str(event.group_id) 244 | game_name = "chart" 245 | if check_game_disable(group_id, game_name): 246 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!") 247 | await isplayingcheck(group_id, matcher) 248 | await matcher.send('连续谱面猜曲已开启,发送\"停止\"以结束') 249 | continuous_stop[group_id] = 1 250 | charts_pool = None 251 | params = args.extract_plain_text().strip().split() 252 | if len(params) == 0: 253 | # 没有参数的连续谱面猜歌(和普通的区别不大) 254 | charts_pool = general_charts_pool 255 | else: 256 | # 有参数的连续谱面猜歌 257 | valid_music = filter_random(chart_total_list, params, 10) # 至少需要有10首歌,不然失去“猜”的价值 258 | if not valid_music: 259 | continuous_stop.pop(group_id) 260 | await matcher.finish(fault_tips) 261 | 262 | groupID_params_map[group_id] = params 263 | sub_path_name = f"{str(event.group_id)}_{'_'.join(params)}" 264 | param_charts_pool.setdefault(sub_path_name, []) 265 | charts_pool = param_charts_pool[sub_path_name] 266 | 267 | # 先进行检验,查看旧的charts_pool里的文件是否都还存在(未被删除) 268 | charts_already_old = [] 269 | for (question_clip_path, answer_clip_path) in charts_pool: 270 | if not os.path.isfile(question_clip_path) or not os.path.isfile(answer_clip_path): 271 | charts_already_old.append((question_clip_path, answer_clip_path)) 272 | for (question_clip_path, answer_clip_path) in charts_already_old: 273 | charts_pool.remove((question_clip_path, answer_clip_path)) 274 | logging.info(f"删除了过时的谱面:{question_clip_path}") 275 | 276 | make_param_chart_task = asyncio.create_task(make_param_chart_video(group_id, params)) 277 | await matcher.send("使用带参数的谱面猜歌,如果猜得太快可能会导致谱面文件来不及制作噢,请稍微耐心等待一下") 278 | 279 | while continuous_stop.get(group_id): 280 | if gameplay_list.get(group_id) is None: 281 | for i in range(30): 282 | if len(charts_pool) > 0: 283 | break 284 | await asyncio.sleep(1) 285 | if len(charts_pool) == 0: 286 | continuous_stop.pop(group_id) 287 | groupID_params_map.pop(group_id) 288 | await matcher.finish("bot主的电脑太慢啦,过了30秒还没有一个谱面制作出来!建议vivo50让我换电脑!", reply_message=True) 289 | (question_clip_path, answer_clip_path) = charts_pool.pop(random.randint(0, len(charts_pool) - 1)) 290 | await guess_chart_handler(group_id, matcher, question_clip_path, answer_clip_path, params) 291 | await asyncio.sleep(1.5) 292 | try: 293 | if continuous_stop[group_id] > 3: 294 | continuous_stop.pop(group_id) 295 | groupID_params_map.pop(group_id) 296 | await matcher.finish('没人猜了? 那我下班了。') 297 | except Exception as e: 298 | logging.error(f"continuous guess chart error: {e}", exc_info=True) 299 | await asyncio.sleep(2.5) 300 | 301 | if len(params) != 0: 302 | # 带参数的谱面猜歌要自己管好自己的文件 303 | now = datetime.now() 304 | now_timestamp = int(now.timestamp()) 305 | groupID_folder2delete_timestamp_list.append((group_id, chart_preload_path / sub_path_name, now_timestamp)) 306 | 307 | 308 | async def guess_chart_handler(group_id, matcher: Matcher, question_clip_path, answer_clip_path, filter_params): 309 | # 进来之前必须保证question视频已经准备好 310 | gameplay_list[group_id] = {} 311 | gameplay_list[group_id]["chart"] = answer_clip_path # 将answer的路径存下来,方便猜歌以及主动停止时使用 312 | charts_save_list.append(question_clip_path) # 存住它,不让这个“单身谱面”被删除,同时也让它不被其它任务当作一个完整谱面 313 | charts_save_list.append(answer_clip_path) 314 | 315 | msg = "这个谱面截取的一个片段如下。请回复“猜歌xxx/开歌xxx”或直接发送别名来猜歌,或回复“不猜了“来停止游戏。" 316 | if len(filter_params): 317 | msg += f"\n本次谱面猜歌范围:{', '.join(filter_params)}" 318 | await matcher.send(msg) 319 | await matcher.send(MessageSegment.video(f"file://{question_clip_path}")) 320 | 321 | os.remove(question_clip_path) # 用完马上删,防止被其它任务拿到 322 | charts_save_list.remove(question_clip_path) 323 | 324 | for _ in range(GUESS_TIME): 325 | await asyncio.sleep(1) 326 | if gameplay_list.get(group_id) is None or not gameplay_list[group_id].get("chart") or gameplay_list[group_id].get("chart") != answer_clip_path: 327 | if continuous_stop.get(group_id): 328 | continuous_stop[group_id] = 1 329 | return 330 | 331 | random_music_id, start_time = split_id_from_path(question_clip_path) 332 | is_remaster = False 333 | if int(random_music_id) > 500000: 334 | # 是白谱,需要找回原始music对象 335 | random_music_id = str(int(random_music_id) % 500000) 336 | is_remaster = True 337 | random_music = total_list.by_id(random_music_id) 338 | 339 | gameplay_list.pop(group_id) # 需要先pop,不然的话发了答案之后还能猜 340 | reply_message = MessageSegment.text("很遗憾,你没有猜到答案,正确的答案是:\n") + song_txt(random_music, is_remaster) + "\n对应的原片段如下:" 341 | await matcher.send(reply_message, reply_message=True) 342 | if answer_clip_path in loading_clip_list: 343 | for i in range(31): 344 | await asyncio.sleep(1) 345 | if answer_clip_path not in loading_clip_list: 346 | break 347 | if i == 30: 348 | await matcher.finish(f"答案文件可能坏掉了,这个谱的开始时间是{start_time}秒,如果感兴趣可以自己去搜索噢", reply_message=True) 349 | await matcher.send(MessageSegment.video(f"file://{answer_clip_path}")) 350 | os.remove(answer_clip_path) 351 | charts_save_list.remove(answer_clip_path) # 发完了就可以解除保护了 352 | if continuous_stop.get(group_id): 353 | continuous_stop[group_id] += 1 354 | 355 | 356 | async def make_param_chart_video(group_id, params): 357 | sub_path_name = f"{str(group_id)}_{'_'.join(params)}" 358 | 359 | param_chart_path: Path = chart_preload_path / sub_path_name 360 | while groupID_params_map.get(group_id) == params: 361 | while len(param_charts_pool[sub_path_name]) >= PARAM_PRELOAD_CNT: 362 | if groupID_params_map.get(group_id) != params: 363 | return 364 | await asyncio.sleep(5) 365 | 366 | random_music = filter_random(chart_total_list, params, 1)[0] # 前面已经做过检验,保证过可以 367 | random_music_id = random_music.id 368 | 369 | music_mp4_file = id_mp4_file_map[random_music_id] 370 | music_mp3_file = id_mp3_file_map[random_music_id] 371 | 372 | # 使用异步线程才能保证其他任务不被影响 373 | start_time, clip_path = await asyncio.to_thread(random_video_clip, music_mp4_file, CLIP_DURATION, random_music_id, param_chart_path) 374 | 375 | make_answer_task = asyncio.create_task(make_answer_video(music_mp4_file, music_mp3_file, random_music_id, param_chart_path, start_time, CLIP_DURATION)) 376 | answer_clip_path = os.path.join(param_chart_path, f"{random_music_id}_{int(start_time)}_answer_clip.mp4") 377 | param_charts_pool[sub_path_name].append((clip_path, answer_clip_path)) 378 | await asyncio.sleep(20) # 这个时间建议取make单个视频的时间(15s)再加5s 379 | 380 | 381 | async def make_chart_video(): 382 | if len(chart_total_list) == 0: 383 | # 如果没有配置任何谱面源文件资源,便直接跳出 384 | return 385 | await init_chart_pool_by_existing_files(chart_preload_path, general_charts_pool) 386 | if len(general_charts_pool) < PRELOAD_CNT: 387 | random_music = random.choice(chart_total_list) 388 | random_music_id = random_music.id 389 | 390 | music_mp4_file = id_mp4_file_map[random_music_id] 391 | music_mp3_file = id_mp3_file_map[random_music_id] 392 | 393 | # 使用异步线程才能保证其他任务不被影响 394 | start_time, clip_path = await asyncio.to_thread(random_video_clip, music_mp4_file, CLIP_DURATION, random_music_id, chart_preload_path) 395 | 396 | make_answer_task = asyncio.create_task(make_answer_video(music_mp4_file, music_mp3_file, random_music_id, chart_preload_path, start_time, CLIP_DURATION)) 397 | answer_clip_path = await make_answer_task 398 | 399 | general_charts_pool.append((clip_path, answer_clip_path)) 400 | 401 | 402 | async def init_chart_pool_by_existing_files(root_directory, pool: List, force = False): 403 | os.makedirs(root_directory, exist_ok=True) 404 | question_pattern = re.compile(r"(\d+)_([\d\.]+)_clip\.mp4") 405 | answer_pattern = re.compile(r"(\d+)_([\d\.]+)_answer_clip\.mp4") 406 | 407 | files = set(os.listdir(root_directory)) 408 | question_files = {(m.group(1), m.group(2)): os.path.join(root_directory, f) for f in files if (m := question_pattern.match(f))} 409 | answer_files = {(m.group(1), m.group(2)): os.path.join(root_directory, f) for f in files if (m := answer_pattern.match(f))} 410 | 411 | matched_keys = question_files.keys() & answer_files.keys() 412 | existing_charts = [] 413 | for key in matched_keys: 414 | if question_files[key] in charts_save_list: 415 | # 说明这个谱面已经被使用了,即使它暂时还同时有question和answer视频对,我们也不能再重复地使用了 416 | continue 417 | existing_charts.append((question_files[key], answer_files[key])) 418 | 419 | old_pool = set(pool) 420 | new_charts = [chart for chart in existing_charts if chart not in old_pool] 421 | pool.extend(new_charts) 422 | 423 | unmatched_files = (set(question_files.values()) | set(answer_files.values())) - set(sum(pool, ())) 424 | for file in unmatched_files: 425 | if force or file not in charts_save_list: 426 | # 只在第一次启动时强制删除单身谱面 427 | os.remove(file) 428 | logging.info(f"Deleted: {file}") 429 | 430 | # 删掉那些参数谱面猜歌的文件夹 431 | global groupID_folder2delete_timestamp_list 432 | todelete_dict = {} 433 | # 只保留更新的删除请求 434 | for groupID, path, timestamp in groupID_folder2delete_timestamp_list: 435 | key = (groupID, path) 436 | 437 | if key not in todelete_dict or timestamp > todelete_dict[key][2]: 438 | todelete_dict[key] = (groupID, path, timestamp) 439 | 440 | groupID_folder2delete_timestamp_list = list(todelete_dict.values()) 441 | for (group_id, folder2delete, timestamp) in groupID_folder2delete_timestamp_list: 442 | if not os.path.exists(folder2delete): 443 | groupID_folder2delete_timestamp_list.remove((group_id, folder2delete, timestamp)) 444 | if continuous_stop.get(group_id) is not None or gameplay_list.get(group_id) is not None: 445 | continue 446 | now = datetime.now() 447 | now_timestamp = int(now.timestamp()) 448 | if now_timestamp - timestamp <= TIME_TO_DELETE: 449 | continue 450 | shutil.rmtree(folder2delete) 451 | groupID_folder2delete_timestamp_list.remove((group_id, folder2delete, timestamp)) 452 | 453 | if force: 454 | # 在启动的时候,删除一些之前的没用的参数谱面 455 | valid_folders = set() 456 | for group_id, params in groupID_params_map.items(): 457 | sub_path_name = f"{group_id}_{'_'.join(params)}" 458 | valid_folders.add(sub_path_name) 459 | 460 | for folder_name in os.listdir(root_directory): 461 | folder_path = os.path.join(root_directory, folder_name) 462 | 463 | if os.path.isdir(folder_path) and folder_name not in valid_folders: 464 | try: 465 | shutil.rmtree(folder_path) 466 | logging.info(f"已删除文件夹: {folder_path}") 467 | except Exception as e: 468 | logging.error(f"删除文件夹 {folder_path} 时出错: {e}", exc_info=True) 469 | 470 | 471 | @check_chart_file_completeness.handle() 472 | async def check_chart_files(matcher: Matcher): 473 | mp3_file_lost_list = [] 474 | mp4_file_lost_list = [] 475 | for music in total_list.music_list: 476 | if music.genre == "\u5bb4\u4f1a\u5834": 477 | continue 478 | if not id_mp3_file_map.get(music.id): 479 | mp3_file_lost_list.append(music.id) 480 | if not id_mp4_file_map.get(music.id): 481 | mp4_file_lost_list.append(music.id) 482 | if len(music.ds) == 5: 483 | # 检测白谱 484 | if not id_mp3_file_map.get(str(int(music.id) + 500000)): 485 | mp3_file_lost_list.append(str(int(music.id) + 500000)) 486 | if not id_mp4_file_map.get(str(int(music.id) + 500000)): 487 | mp4_file_lost_list.append(str(int(music.id) + 500000)) 488 | if len(mp3_file_lost_list) == 0 and len(mp4_file_lost_list) == 0: 489 | await matcher.finish("mp3和mp4文件均完整,请放心进行谱面猜歌") 490 | if len(mp3_file_lost_list) != 0: 491 | await matcher.send(f"当前一共缺失了{len(mp3_file_lost_list)}首mp3文件,缺失的歌曲id如下{','.join(mp3_file_lost_list)}。其中大于500000的id是白谱资源。") 492 | if len(mp4_file_lost_list) != 0: 493 | await matcher.send(f"当前一共缺失了{len(mp4_file_lost_list)}首mp4文件,缺失的歌曲id如下{','.join(mp4_file_lost_list)}。其中大于500000的id是白谱资源。") 494 | 495 | 496 | async def init_chart_guess_info(): 497 | global id_music_map 498 | for dirpath, dirnames, filenames in os.walk(chart_file_path): 499 | for file in filenames: 500 | file_id = os.path.splitext(file)[0] 501 | if "remaster" in dirpath: 502 | file_id = str(500000 + int(file_id)) 503 | if file.endswith(".mp3"): 504 | id_mp3_file_map[file_id] = os.path.join(dirpath, file) 505 | elif file.endswith(".mp4"): 506 | id_mp4_file_map[file_id] = os.path.join(dirpath, file) 507 | common_music_list = list(id_mp3_file_map.keys() & id_mp4_file_map.keys()) 508 | id_to_music = {music.id: music for music in total_list.music_list} 509 | id_music_map = {id: id_to_music[str(int(id) % 500000)] for id in common_music_list if str(int(id) % 500000) in id_to_music} 510 | for id, music in id_music_map.items(): 511 | new_music = copy.deepcopy(music) 512 | if int(id) > 500000: 513 | # 白谱处理,将白谱的内容(定数、谱面信息等全部顶到紫谱的位置) 514 | new_music.id = id 515 | new_music.ds[3] = new_music.ds[4] 516 | new_music.level[3] = new_music.level[4] 517 | new_music.cids[3] = new_music.cids[4] 518 | new_music.charts[3] = new_music.charts[4] 519 | 520 | if len(new_music.ds) == 5: 521 | # 即有白谱的music 522 | new_music.ds.pop(4) 523 | new_music.level.pop(4) 524 | new_music.cids.pop(4) 525 | new_music.charts.pop(4) 526 | chart_total_list.append(new_music) 527 | 528 | 529 | asyncio.run(init_chart_guess_info()) 530 | asyncio.run(init_chart_pool_by_existing_files(chart_preload_path, general_charts_pool, True)) 531 | scheduler.add_job(make_chart_video, 'interval', seconds=PRELOAD_CHECK_TIME) 532 | -------------------------------------------------------------------------------- /nonebot_plugin_guess_song/libraries/guess_song_clue.py: -------------------------------------------------------------------------------- 1 | import random 2 | import asyncio 3 | from PIL import Image 4 | from pydub import AudioSegment # 请注意,这里还需要ffmpeg,请自行安装 5 | 6 | from .utils import * 7 | from .music_model import gameplay_list, game_alias_map, alias_dict, total_list, Music, continuous_stop 8 | from .guess_cover import image_to_base64 9 | 10 | from nonebot import on_fullmatch, on_command 11 | from nonebot.matcher import Matcher 12 | from nonebot.adapters.onebot.v11 import Message, GroupMessageEvent 13 | from nonebot.params import CommandArg 14 | 15 | 16 | guess_clue = on_command("线索猜歌", aliases={"猜歌"}, priority=5) 17 | continuous_guess_clue = on_command('连续线索猜歌', priority=5) 18 | 19 | async def pic(path: str) -> Image.Image: 20 | """裁切曲绘""" 21 | im = Image.open(path) 22 | w, h = im.size 23 | w2, h2 = int(w / 3), int(h / 3) 24 | l, u = random.randrange(0, int(2 * w / 3)), random.randrange(0, int(2 * h / 3)) 25 | im = im.crop((l, u, l + w2, u + h2)) 26 | return im 27 | 28 | 29 | async def get_clues(music: Music): 30 | clue_list = [ 31 | f'的 Expert 难度是 {music.level[2]}', 32 | f'的分类是 {music.genre}', 33 | f'的版本是 {music.version}', 34 | f'的 BPM 是 {music.bpm}', 35 | f'的紫谱有 {music.charts[3].note_sum} 个note,而且有 {music.charts[3].brk} 个绝赞' 36 | ] 37 | vital_clue = [ 38 | f'的 Master 难度是 {music.level[3]}', 39 | f'的艺术家是 {music.artist}', 40 | f'的紫谱谱师为{music.charts[3].charter}', 41 | ] 42 | title = list(music.title) 43 | random.shuffle(title) 44 | title = ''.join(title) 45 | final_clue = f'的歌名组成为{title}' 46 | 47 | if music_file_path: 48 | # 若有歌曲文件,就加入歌曲长度作为线索 49 | music_file = get_music_file_path(music) 50 | try: 51 | song = AudioSegment.from_file(music_file) 52 | song_length = len(song) / 1000 53 | 54 | clue_list.append(f'的歌曲长度为 {int(song_length/60)}分{int(song_length%60)}秒') 55 | except Exception as e: 56 | logging.error(e, exc_info=True) 57 | 58 | # 特殊属性加入重要线索 59 | if total_list.by_id(str(int(music.id) % 10000)) and total_list.by_id(str(int(music.id) % 10000 + 10000)): 60 | # 如果sd和dx谱都存在的话 61 | vital_clue.append(f'既有SD谱面也有DX谱面') 62 | else: 63 | clue_list.append(f'{"不" if music.type == "SD" else ""}是 DX 谱面') 64 | if total_list.by_id(str(int(music.id) + 100000)): 65 | vital_clue.append(f'有宴谱') 66 | 67 | if len(music.ds) == 5: 68 | vital_clue.append(f'的白谱谱师为{music.charts[4].charter}') 69 | clue_list.append(f'的 Re:Master 难度是 {music.level[4]}') 70 | else: 71 | clue_list.append(f'没有白谱') 72 | 73 | random_clue = vital_clue 74 | random_clue += random.sample(clue_list, 6 - len(vital_clue)) 75 | random.shuffle(random_clue) 76 | random_clue.append(final_clue) 77 | return random_clue 78 | 79 | 80 | @guess_clue.handle() 81 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()): 82 | if len(total_list.music_list) == 0: 83 | await matcher.finish("本插件还没有配置好static资源噢,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!") 84 | group_id = str(event.group_id) 85 | game_name = "clue" 86 | if check_game_disable(group_id, game_name): 87 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!") 88 | params = args.extract_plain_text().strip().split() 89 | await isplayingcheck(group_id, matcher) 90 | await clue_guess_handler(group_id, matcher, params) 91 | 92 | @continuous_guess_clue.handle() 93 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()): 94 | if len(total_list.music_list) == 0: 95 | await matcher.finish("本插件还没有配置好static资源噢,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!") 96 | group_id = str(event.group_id) 97 | game_name = "clue" 98 | if check_game_disable(group_id, game_name): 99 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!") 100 | params = args.extract_plain_text().strip().split() 101 | await isplayingcheck(group_id, matcher) 102 | if not filter_random(total_list.music_list, params, 1): 103 | await matcher.finish(fault_tips, reply_message=True) 104 | await matcher.send('连续线索猜歌已开启,发送\"停止\"以结束') 105 | continuous_stop[group_id] = 1 106 | while continuous_stop.get(group_id): 107 | if gameplay_list.get(group_id) is None: 108 | await clue_guess_handler(group_id, matcher, params) 109 | if continuous_stop[group_id] > 3: 110 | continuous_stop.pop(group_id) 111 | await matcher.finish('没人猜了? 那我下班了。') 112 | await asyncio.sleep(1) 113 | 114 | async def clue_guess_handler(group_id, matcher: Matcher, args): 115 | if _ := filter_random(total_list.music_list, args, 1): 116 | random_music = _[0] 117 | else: 118 | await matcher.finish(fault_tips, reply_message=True) 119 | clues = await get_clues(random_music) 120 | gameplay_list[group_id] = {} 121 | gameplay_list[group_id]["clue"] = random_music 122 | 123 | msg = "我将每隔8秒给你一些和这首歌相关的线索,直接输入歌曲的 id、标题、有效别名 都可以进行猜歌。" 124 | if len(args): 125 | msg += f"\n本次线索猜歌范围:{', '.join(args)}" 126 | await matcher.send(msg) 127 | await asyncio.sleep(5) 128 | for cycle in range(8): 129 | if group_id not in gameplay_list or not gameplay_list[group_id].get("clue"): 130 | break 131 | if gameplay_list[group_id].get("clue") != random_music: 132 | break 133 | 134 | if cycle < 5: 135 | await matcher.send(f'[线索 {cycle + 1}/8] 这首歌{clues[cycle]}') 136 | await asyncio.sleep(8) 137 | elif cycle < 7: 138 | # 最后俩线索太明显,多等一会 139 | await matcher.send(f'[线索 {cycle + 1}/8] 这首歌{clues[cycle]}') 140 | await asyncio.sleep(12) 141 | else: 142 | pic_path = music_cover_path / (get_cover_len5_id(random_music.id) + ".png") 143 | draw = await pic(pic_path) 144 | await matcher.send( 145 | MessageSegment.text('[线索 8/8] 这首歌封面的一部分是:\n') + 146 | MessageSegment.image(image_to_base64(draw)) + 147 | MessageSegment.text('答案将在30秒后揭晓') 148 | ) 149 | for _ in range(30): 150 | await asyncio.sleep(1) 151 | if gameplay_list.get(group_id) is None or not gameplay_list[group_id].get("clue") or gameplay_list[group_id].get("clue") != random_music: 152 | if continuous_stop.get(group_id): 153 | continuous_stop[group_id] = 1 154 | return 155 | 156 | gameplay_list.pop(group_id) 157 | reply_message = MessageSegment.text("很遗憾,你没有猜到答案,正确的答案是:\n") + song_txt(random_music) 158 | await matcher.send(reply_message) 159 | if continuous_stop.get(group_id): 160 | continuous_stop[group_id] += 1 161 | 162 | 163 | async def clue_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag): 164 | '''开歌处理函数''' 165 | clue_info = gameplay_list.get(group_id).get("clue") 166 | music_candidates = alias_dict.get(song_name) 167 | if music_candidates is None: 168 | if ignore_tag: 169 | return 170 | await matcher.finish("没有找到这样的乐曲。请输入正确的名称或别名", reply_message=True) 171 | 172 | if len(music_candidates) < 20: 173 | for music_index in music_candidates: 174 | music = total_list.music_list[music_index] 175 | if clue_info.id == music.id: 176 | gameplay_list.pop(group_id) 177 | record_game_success(user_id=user_id, group_id=group_id, game_type="clue") 178 | reply_message = MessageSegment.text("恭喜你猜对啦!答案就是:\n") + song_txt(music) 179 | await matcher.finish(Message(reply_message), reply_message=True) 180 | if not ignore_tag: 181 | await matcher.finish("你猜的答案不对噢,再仔细听一下吧!", reply_message=True) 182 | 183 | 184 | def clue_rank_message(group_id): 185 | top_clue = get_top_three(group_id, "clue") 186 | if top_clue: 187 | msg = "今天的前三名线索猜歌王:\n" 188 | for rank, (user_id, count) in enumerate(top_clue, 1): 189 | msg += f"{rank}. {MessageSegment.at(user_id)} 猜对了{count}首歌!\n" 190 | msg += "你们赢了……" 191 | return msg -------------------------------------------------------------------------------- /nonebot_plugin_guess_song/libraries/guess_song_listen.py: -------------------------------------------------------------------------------- 1 | import random 2 | import asyncio 3 | from pydub import AudioSegment # 请注意,这里还需要ffmpeg,请自行安装 4 | 5 | from .utils import * 6 | from .music_model import gameplay_list, game_alias_map, alias_dict, total_list, continuous_stop 7 | 8 | from nonebot import on_fullmatch, on_command 9 | from nonebot.matcher import Matcher 10 | from nonebot.adapters.onebot.v11 import Message, GroupMessageEvent 11 | from nonebot.params import CommandArg 12 | from nonebot.permission import SUPERUSER 13 | 14 | 15 | guess_listen = on_command("听歌猜曲", aliases={"听歌辩曲"}, priority=5) 16 | continuous_guess_listen = on_command('连续听歌猜曲', priority=5) 17 | check_listen_file_completeness = on_command("检查歌曲文件完整性", permission=SUPERUSER, priority=5) 18 | 19 | listen_total_list: List[Music] = [] 20 | 21 | def extract_random_clip(music_file, duration_ms=10000): 22 | song = AudioSegment.from_file(music_file) 23 | song_length = len(song) 24 | if song_length <= duration_ms: 25 | return song 26 | start_ms = random.randint(0, song_length - duration_ms) 27 | end_ms = start_ms + duration_ms 28 | clip = song[start_ms:end_ms] 29 | return clip 30 | 31 | 32 | def save_clip_as_audio(clip, output_file): 33 | '''将截取的片段保存为音频文件''' 34 | clip.export(output_file, format="mp3", bitrate="320k", parameters=["-ar", "44100"]) 35 | 36 | 37 | def listen_rank_message(group_id): 38 | top_listen = get_top_three(group_id, "listen") 39 | if top_listen: 40 | msg = "今天的前三名猜歌王:\n" 41 | for rank, (user_id, count) in enumerate(top_listen, 1): 42 | msg += f"{rank}. {MessageSegment.at(user_id)} 猜对了{count}首歌!\n" 43 | msg += "一堆wmc。。。" 44 | return msg 45 | 46 | 47 | async def listen_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag): 48 | '''开歌处理函数''' 49 | listen_info = gameplay_list.get(group_id).get("listen") 50 | music_candidates = alias_dict.get(song_name) 51 | if music_candidates is None: 52 | if ignore_tag: 53 | return 54 | await matcher.finish("没有找到这样的乐曲。请输入正确的名称或别名", reply_message=True) 55 | 56 | if len(music_candidates) < 20: 57 | for music_index in music_candidates: 58 | music = total_list.music_list[music_index] 59 | if listen_info.id == music.id: 60 | gameplay_list.pop(group_id) 61 | record_game_success(user_id=user_id, group_id=group_id, game_type="listen") 62 | reply_message = MessageSegment.text("恭喜你猜对啦!答案就是:\n") + song_txt(music) 63 | await matcher.finish(Message(reply_message), reply_message=True) 64 | if not ignore_tag: 65 | await matcher.finish("你猜的答案不对噢,再仔细听一下吧!", reply_message=True) 66 | 67 | 68 | @guess_listen.handle() 69 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()): 70 | group_id = str(event.group_id) 71 | game_name = "listen" 72 | if check_game_disable(group_id, game_name): 73 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!") 74 | params = args.extract_plain_text().strip().split() 75 | await isplayingcheck(group_id, matcher) 76 | await listen_guess_handler(group_id, matcher, params) 77 | 78 | @continuous_guess_listen.handle() 79 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()): 80 | if len(listen_total_list) == 0: 81 | await matcher.finish("文件夹没有配置任何音乐资源,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!") 82 | group_id = str(event.group_id) 83 | game_name = "listen" 84 | if check_game_disable(group_id, game_name): 85 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!") 86 | params = args.extract_plain_text().strip().split() 87 | await isplayingcheck(group_id, matcher) 88 | if not filter_random(listen_total_list, params, 1): 89 | await matcher.finish(fault_tips, reply_message=True) 90 | await matcher.send('连续听歌猜曲已开启,发送\"停止\"以结束') 91 | continuous_stop[group_id] = 1 92 | while continuous_stop.get(group_id): 93 | if gameplay_list.get(group_id) is None: 94 | await listen_guess_handler(group_id, matcher, params) 95 | if continuous_stop[group_id] > 3: 96 | continuous_stop.pop(group_id) 97 | await matcher.finish('没人猜了? 那我下班了。') 98 | await asyncio.sleep(1) 99 | 100 | async def listen_guess_handler(group_id, matcher: Matcher, args): 101 | if len(listen_total_list) == 0: 102 | await matcher.finish("文件夹没有配置任何音乐资源,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!") 103 | if _ := filter_random(listen_total_list, args, 1): 104 | random_music = _[0] 105 | else: 106 | await matcher.finish(fault_tips, reply_message=True) 107 | music_file = get_music_file_path(random_music) 108 | if not os.path.isfile(music_file): 109 | await matcher.finish(f"文件夹中没有{random_music.title}的音乐文件,请让bot主尽快补充吧!") 110 | msg = "这首歌截取的一个片段如下,请回复\"猜歌xxx\"提交您认为在答案中的曲目(可以是歌曲的别名),或回复\"不猜了\"来停止游戏" 111 | if len(args): 112 | msg += f"\n本次听歌猜曲范围:{', '.join(args)}" 113 | await matcher.send(msg) 114 | gameplay_list[group_id] = {} 115 | gameplay_list[group_id]["listen"] = random_music 116 | 117 | clip = extract_random_clip(music_file) 118 | 119 | output_file = f"./{group_id}clip.mp3" 120 | output_file = convert_to_absolute_path(output_file) 121 | save_clip_as_audio(clip, output_file) 122 | 123 | # 发送语音到群组 124 | await matcher.send(MessageSegment.record(f"file://{output_file}")) 125 | os.remove(output_file) 126 | 127 | for _ in range(30): 128 | await asyncio.sleep(1) 129 | if gameplay_list.get(group_id) is None or not gameplay_list[group_id].get("listen") or gameplay_list[group_id].get("listen") != random_music: 130 | if continuous_stop.get(group_id): 131 | continuous_stop[group_id] = 1 132 | return 133 | 134 | gameplay_list.pop(group_id) 135 | reply_message = MessageSegment.text("很遗憾,你没有猜到答案,正确的答案是:\n") + song_txt(random_music) 136 | await matcher.send(reply_message) 137 | if continuous_stop.get(group_id): 138 | continuous_stop[group_id] += 1 139 | 140 | 141 | @check_listen_file_completeness.handle() 142 | async def check_listen_files(matcher: Matcher): 143 | mp3_files = [] 144 | for filename in os.listdir(music_file_path): 145 | if filename.endswith(".mp3"): 146 | mp3_files.append(filename.replace(".mp3", "")) 147 | mp3_lost_files = [] 148 | for music in total_list.music_list: 149 | music_file_name = (int)(music.id) 150 | music_file_name %= 10000 151 | music_file_name = str(music_file_name) 152 | if music_file_name not in mp3_files: 153 | mp3_lost_files.append(music_file_name) 154 | if len(mp3_lost_files) == 0: 155 | await matcher.finish("听歌猜曲的所有文件均完整,请放心进行听歌猜曲") 156 | if len(mp3_lost_files) != 0: 157 | await matcher.send(f"当前一共缺失了{len(mp3_lost_files)}首mp3文件,缺失的歌曲id如下{','.join(mp3_lost_files)}") 158 | 159 | 160 | async def init_listen_guess_list(): 161 | if not music_file_path.is_dir(): 162 | # 如果没有配置猜歌资源,则跳过 163 | return 164 | mp3_files = [] 165 | for filename in os.listdir(music_file_path): 166 | if filename.endswith(".mp3"): 167 | mp3_files.append(filename.replace(".mp3", "")) 168 | for music in total_list.music_list: 169 | # 过滤出有资源的music 170 | music_file_name = (int)(music.id) 171 | music_file_name %= 10000 172 | music_file_name = str(music_file_name) 173 | if music_file_name in mp3_files: 174 | listen_total_list.append(music) 175 | 176 | 177 | asyncio.run(init_listen_guess_list()) -------------------------------------------------------------------------------- /nonebot_plugin_guess_song/libraries/guess_song_note.py: -------------------------------------------------------------------------------- 1 | import re 2 | import copy 3 | import random 4 | import shutil 5 | import asyncio 6 | import subprocess 7 | 8 | from .utils import * 9 | from .music_model import gameplay_list, game_alias_map, alias_dict, total_list, continuous_stop 10 | from .guess_song_chart import chart_total_list, id_mp4_file_map, id_mp3_file_map 11 | 12 | from nonebot import on_fullmatch, on_command 13 | from nonebot.plugin import require 14 | from nonebot.matcher import Matcher 15 | from nonebot.params import CommandArg 16 | from nonebot.permission import SUPERUSER 17 | from nonebot.adapters.onebot.v11 import Message, GroupMessageEvent 18 | 19 | 20 | CLIP_DURATION = 30 21 | GUESS_TIME = 90 22 | 23 | guess_note = on_command("note猜歌", aliases={"note音猜歌"}, priority=5) 24 | continuous_guess_note = on_command("连续note猜歌", aliases={"连续note音猜歌"}, priority=5) 25 | 26 | 27 | def make_note_sound(input_path, start_time, output_path): 28 | command = [ 29 | "ffmpeg", 30 | "-i", input_path, 31 | "-ss", seconds_to_hms(start_time), 32 | "-t", str(CLIP_DURATION), 33 | "-q:a", "0", 34 | output_path 35 | ] 36 | try: 37 | logging.info("Cutting note sound...") 38 | subprocess.run(command, check=True) 39 | logging.info(f"Cutting completed. File saved as '{output_path}'.") 40 | except subprocess.CalledProcessError as e: 41 | logging.error(f"Error during cutting: {e}", exc_info=True) 42 | except Exception as ex: 43 | logging.error(f"An unexpected error occurred: {ex}", exc_info=True) 44 | 45 | 46 | def note_rank_message(group_id): 47 | top_listen = get_top_three(group_id, "note") 48 | if top_listen: 49 | msg = "今天的前三名note音猜歌王:\n" 50 | for rank, (user_id, count) in enumerate(top_listen, 1): 51 | msg += f"{rank}. {MessageSegment.at(user_id)} 猜对了{count}首歌!\n" 52 | msg += "这都听得出,你们也是无敌了。。。" 53 | return msg 54 | 55 | 56 | async def note_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag): 57 | '''开歌处理函数''' 58 | (answer_music, start_time) = gameplay_list.get(group_id).get("note") 59 | random_music_id = answer_music.id 60 | is_remaster = False 61 | if int(random_music_id) > 500000: 62 | # 是白谱,需要找回原始music对象 63 | random_music_id = str(int(random_music_id) % 500000) 64 | is_remaster = True 65 | random_music = total_list.by_id(random_music_id) 66 | 67 | music_candidates = alias_dict.get(song_name) 68 | if music_candidates is None: 69 | if ignore_tag: 70 | return 71 | await matcher.finish("没有找到这样的乐曲。请输入正确的名称或别名", reply_message=True) 72 | 73 | if len(music_candidates) < 20: 74 | for music_index in music_candidates: 75 | music = total_list.music_list[music_index] 76 | if random_music.id == music.id: 77 | gameplay_list.pop(group_id) 78 | record_game_success(user_id=user_id, group_id=group_id, game_type="note") 79 | reply_message = MessageSegment.text("恭喜你猜对啦!答案就是:\n") + song_txt(random_music, is_remaster) + "\n对应的片段如下:" 80 | await matcher.send(Message(reply_message), reply_message=True) 81 | answer_input_path = id_mp3_file_map.get(answer_music.id) # 这个是旧的id,即白谱应该找回白谱的id 82 | answer_output_path: Path = guess_resources_path / f"{group_id}_{start_time}_answer.mp3" 83 | await asyncio.to_thread(make_note_sound, answer_input_path, start_time, answer_output_path) 84 | await matcher.send(MessageSegment.record(f"file://{answer_output_path}")) 85 | os.remove(answer_output_path) 86 | if not ignore_tag: 87 | await matcher.finish("你猜的答案不对噢,再仔细听一下吧!", reply_message=True) 88 | 89 | 90 | @guess_note.handle() 91 | async def note_guess_handler(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()): 92 | group_id = str(event.group_id) 93 | game_name = "note" 94 | if check_game_disable(group_id, game_name): 95 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!") 96 | await isplayingcheck(group_id, matcher) 97 | params = args.extract_plain_text().strip().split() 98 | await note_guess_handler(group_id, matcher, params) 99 | 100 | 101 | @continuous_guess_note.handle() 102 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()): 103 | if len(chart_total_list) == 0: 104 | await matcher.finish("文件夹没有下载任何谱面视频资源,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!需要配置好谱面资源才能进行note音猜歌噢") 105 | group_id = str(event.group_id) 106 | game_name = "note" 107 | if check_game_disable(group_id, game_name): 108 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!") 109 | params = args.extract_plain_text().strip().split() 110 | await isplayingcheck(group_id, matcher) 111 | if not filter_random(chart_total_list, params, 1): 112 | await matcher.finish(fault_tips, reply_message=True) 113 | await matcher.send('连续note音猜歌已开启,发送\"停止\"以结束') 114 | continuous_stop[group_id] = 1 115 | while continuous_stop.get(group_id): 116 | if gameplay_list.get(group_id) is None: 117 | await note_guess_handler(group_id, matcher, params) 118 | if continuous_stop[group_id] > 3: 119 | continuous_stop.pop(group_id) 120 | await matcher.finish('没人猜了? 那我下班了。') 121 | await asyncio.sleep(1) 122 | 123 | 124 | async def note_guess_handler(group_id, matcher: Matcher, args): 125 | if len(chart_total_list) == 0: 126 | await matcher.finish("文件夹没有下载任何谱面视频资源,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!需要配置好谱面资源才能进行note音猜歌噢") 127 | if _ := filter_random(chart_total_list, args, 1): 128 | random_music = _[0] 129 | else: 130 | await matcher.finish(fault_tips, reply_message=True) 131 | input_path = id_mp4_file_map.get(random_music.id) 132 | video_duration = get_video_duration(input_path) 133 | if CLIP_DURATION > video_duration - 15: 134 | raise ValueError(f"截取时长不能超过视频总时长减去15秒") 135 | 136 | start_time = random.uniform(5, video_duration - CLIP_DURATION - 10) 137 | output_path: Path = guess_resources_path / f"{group_id}_{start_time}.mp3" 138 | await asyncio.to_thread(make_note_sound, input_path, start_time, output_path) 139 | msg = "这首歌的谱面截取的一个片段的note音如下,请回复\"猜歌xxx\"提交您认为在答案中的曲目(可以是歌曲的别名),或回复\"不猜了\"来停止游戏" 140 | if len(args): 141 | msg += f"\n本次note音猜歌范围:{', '.join(args)}" 142 | await matcher.send(msg) 143 | gameplay_list[group_id] = {} 144 | gameplay_list[group_id]["note"] = (random_music, start_time) 145 | 146 | # 发送语音到群组 147 | await matcher.send(MessageSegment.record(f"file://{output_path}")) 148 | os.remove(output_path) 149 | 150 | for _ in range(GUESS_TIME): 151 | await asyncio.sleep(1) 152 | if gameplay_list.get(group_id) is None or not gameplay_list[group_id].get("note") or gameplay_list[group_id].get("note") != random_music: 153 | if continuous_stop.get(group_id): 154 | continuous_stop[group_id] = 1 155 | return 156 | 157 | answer_music_id = random_music.id 158 | random_music_id = random_music.id 159 | is_remaster = False 160 | if int(random_music_id) > 500000: 161 | # 是白谱,需要找回原始music对象 162 | random_music_id = str(int(random_music_id) % 500000) 163 | is_remaster = True 164 | random_music = total_list.by_id(random_music_id) 165 | gameplay_list.pop(group_id) 166 | reply_message = MessageSegment.text("很遗憾,你没有猜到答案,正确的答案是:\n") + song_txt(random_music, is_remaster) + "\n对应的片段如下:" 167 | await matcher.send(reply_message) 168 | answer_input_path = id_mp3_file_map.get(answer_music_id) # 这个是旧的id,即白谱应该找回白谱的id 169 | answer_output_path: Path = guess_resources_path / f"{group_id}_{start_time}_answer.mp3" 170 | await asyncio.to_thread(make_note_sound, answer_input_path, start_time, answer_output_path) 171 | await matcher.send(MessageSegment.record(f"file://{answer_output_path}")) 172 | os.remove(answer_output_path) 173 | if continuous_stop.get(group_id): 174 | continuous_stop[group_id] += 1 -------------------------------------------------------------------------------- /nonebot_plugin_guess_song/libraries/music_model.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | import asyncio 4 | from typing import Dict, List, Optional 5 | 6 | from ..config import * 7 | 8 | from nonebot import get_plugin_config 9 | 10 | 11 | config = get_plugin_config(Config) 12 | 13 | 14 | def contains_japanese(text): 15 | # 日文字符的 Unicode 范围包括平假名、片假名、以及部分汉字 16 | japanese_pattern = re.compile(r'[\u3040-\u30FF\u4E00-\u9FAF]') 17 | return bool(japanese_pattern.search(text)) 18 | 19 | 20 | class Chart(): 21 | tap: Optional[int] = None 22 | slide: Optional[int] = None 23 | hold: Optional[int] = None 24 | brk: Optional[int] = None 25 | touch: Optional[int] = None 26 | charter: Optional[str] = None 27 | note_sum: Optional[int] = None 28 | 29 | def __init__(self, data: Dict): 30 | note_list = data.get('notes') 31 | self.tap = note_list[0] 32 | self.hold = note_list[1] 33 | self.slide = note_list[2] 34 | self.brk = note_list[3] 35 | if len(note_list) == 5: 36 | self.touch = note_list[3] 37 | self.brk = note_list[4] 38 | else: 39 | self.touch = 0 40 | self.charter = data.get('charter') 41 | self.note_sum = self.tap + self.slide + self.hold + self.brk + self.touch 42 | 43 | 44 | class Music(): 45 | id: Optional[str] = None 46 | title: Optional[str] = None 47 | type: Optional[str] = None 48 | ds: Optional[List[float]] = None 49 | level: Optional[List[str]] = None 50 | cids: Optional[List[int]] = None 51 | charts: Optional[List[Chart]] = None 52 | artist: Optional[str] = None 53 | genre: Optional[str] = None 54 | bpm: Optional[float] = None 55 | release_date: Optional[str] = None 56 | version: Optional[str] = None 57 | is_new: Optional[bool] = None 58 | 59 | diff: List[int] = [] 60 | alias: List[str] = [] 61 | 62 | def __init__(self, data: Dict): 63 | # 从字典中获取值并设置类的属性 64 | self.id: Optional[str] = data.get('id') 65 | self.title: Optional[str] = data.get('title') 66 | self.type: Optional[str] = data.get('type') 67 | self.ds: Optional[List[float]] = data.get('ds') 68 | self.level: Optional[List[str]] = data.get('level') 69 | self.cids: Optional[List[int]] = data.get('cids') 70 | self.charts: Optional[List[Chart]] = [Chart(chart) for chart in data.get('charts')] 71 | self.artist: Optional[str] = data.get('basic_info').get('artist') 72 | self.genre: Optional[str] = data.get('basic_info').get('genre') 73 | self.bpm: Optional[float] = data.get('basic_info').get('bpm') 74 | self.release_date: Optional[str] = data.get('basic_info').get('release_date') 75 | self.version: Optional[str] = data.get('basic_info').get('from') 76 | self.is_new: Optional[bool] = data.get('basic_info').get('is_new') 77 | 78 | class MusicList(): 79 | music_list: Optional[List[Music]] = [] 80 | 81 | def __init__(self, data: List): 82 | for music_data in data: 83 | music = Music(music_data) 84 | self.music_list.append(music) 85 | 86 | def init_alias(self, data: Dict): 87 | cache_dict = {} 88 | for alias_info in data: 89 | cache_dict[str(alias_info.get("SongID"))] = alias_info.get("Alias") 90 | for music in self.music_list: 91 | if cache_dict.get(music.id): 92 | music.alias = cache_dict.get(music.id) 93 | else: 94 | music.alias = [] 95 | 96 | 97 | def by_id(self, music_id: str) -> Optional[Music]: 98 | for music in self.music_list: 99 | if music.id == music_id: 100 | return music 101 | return None 102 | 103 | 104 | async def main(): 105 | 106 | global total_list, alias_dict, filter_list 107 | 108 | def load_data(data_path): 109 | try: 110 | with open(data_path, 'r', encoding='utf-8') as f: 111 | return json.load(f) 112 | except FileNotFoundError: 113 | return {} 114 | 115 | music_data = load_data(music_info_path) 116 | total_list = MusicList(music_data) 117 | 118 | alias_data = load_data(music_alias_path) 119 | total_list.init_alias(alias_data) 120 | 121 | alias_dict = {} 122 | for i in range(len(total_list.music_list)): 123 | 124 | # 将歌曲的id也加入别名 125 | id = total_list.music_list[i].id 126 | if alias_dict.get(id) is None: 127 | alias_dict[id] = [i] 128 | else: 129 | alias_dict[id].append(i) 130 | 131 | title = total_list.music_list[i].title 132 | 133 | # 将歌曲的原名也加入别名(统一转为小写) 134 | title_lower = title.lower() 135 | if alias_dict.get(title_lower) is None: 136 | alias_dict[title_lower] = [i] 137 | else: 138 | alias_dict[title_lower].append(i) 139 | 140 | for alias in total_list.music_list[i].alias: 141 | # 将别名库中的别名载入到内存字典中(统一转为小写) 142 | alias_lower = alias.lower() 143 | if alias_dict.get(alias_lower) is None: 144 | alias_dict[alias_lower] = [i] 145 | else: 146 | alias_dict[alias_lower].append(i) 147 | 148 | # 对别名字典进行去重 149 | for key, value_list in alias_dict.items(): 150 | alias_dict[key] = list(set(value_list)) 151 | 152 | # 选出没有日文字的歌曲,作为开字母的曲库(因为如果曲名有日文字很难开出来) 153 | filter_list = [] 154 | for music in total_list.music_list: 155 | if (not game_config.character_filter_japenese) or (not contains_japanese(music.title)): 156 | # 如果不过滤那就都可以加,如果要过滤,那就不含有日文才能加 157 | filter_list.append(music) 158 | 159 | gameplay_list = {} 160 | continuous_stop = {} 161 | game_alias_map = { 162 | "open_character" : "开字母", 163 | "listen" : "听歌猜曲", 164 | "cover" : "猜曲绘", 165 | "clue" : "线索猜歌", 166 | "chart" : "谱面猜歌", 167 | "random" : "随机猜歌", 168 | "note": "note音猜歌", 169 | } 170 | 171 | game_alias_map_reverse = { 172 | "开字母" : "open_character", 173 | "听歌猜曲" : "listen", 174 | "猜曲绘" : "cover", 175 | "线索猜歌" : "clue", 176 | "谱面猜歌" : "chart", 177 | "随机猜歌" : "random", 178 | "note音猜歌": "note", 179 | } 180 | 181 | asyncio.run(main()) 182 | -------------------------------------------------------------------------------- /nonebot_plugin_guess_song/libraries/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from datetime import datetime 4 | from io import BytesIO 5 | import base64 6 | from PIL import Image 7 | from typing import Union, Optional 8 | import random 9 | import subprocess 10 | from PIL import Image, ImageDraw, ImageFont 11 | import logging 12 | 13 | from .music_model import * 14 | from ..config import * 15 | 16 | from nonebot.adapters.onebot.v11 import Message, MessageSegment, Bot 17 | from nonebot.matcher import Matcher 18 | 19 | 20 | def convert_to_absolute_path(input_path): 21 | input_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), input_path) 22 | input_path = os.path.normpath(input_path) 23 | return input_path 24 | 25 | 26 | def split_id_from_path(file_path): 27 | if file_path.find('/') != -1: 28 | # Linux下通过斜杠/来分割 29 | file_name = file_path.split('/')[-1] 30 | elif file_path.find('\\') != -1: 31 | # Windows下通过反斜杠\来分割 32 | file_name = file_path.split('\\')[-1] 33 | music_id = file_name.split('_')[0] 34 | start_time = file_name.split('_')[1] 35 | return music_id, start_time 36 | 37 | 38 | def get_cover_len5_id(mid) -> str: 39 | '''获取曲绘id''' 40 | mid = int(mid) 41 | mid %= 10000 42 | if mid > 10000 and mid <= 11000: 43 | mid -= 10000 44 | if mid > 1000 and mid <= 10000: 45 | mid += 10000 46 | return str(mid) 47 | 48 | 49 | def get_music_file_path(music: Music): 50 | '''获取音频路径''' 51 | music_file_name = (int)(music.id) 52 | music_file_name %= 10000 53 | music_file_name = str(music_file_name) 54 | music_file_name += ".mp3" 55 | music_file = os.path.join(music_file_path, music_file_name) 56 | return music_file 57 | 58 | 59 | def song_txt(music: Music, is_remaster: bool = False): 60 | '''返回歌曲介绍的message''' 61 | output = ( 62 | f"{music.id}. {music.title}\n" 63 | f"艺术家:{music.artist}\n" 64 | f"分类:{music.genre}\n" 65 | f"版本:{music.version}\n" 66 | f"{f'紫谱谱师:{music.charts[3].charter}' if not is_remaster else f'白谱谱师:{music.charts[4].charter}'}\n" 67 | f"BPM:{music.bpm}\n" 68 | f"定数:{'/'.join(map(str, music.ds))}" 69 | ) 70 | pic_path = music_cover_path / (get_cover_len5_id(music.id) + ".png") 71 | #print(pic_path) 72 | return [ 73 | MessageSegment.image(f"file://{pic_path}"), 74 | MessageSegment("text", {"text": output})] 75 | 76 | 77 | def image_to_base64(img: Image.Image, format='PNG') -> str: 78 | output_buffer = BytesIO() 79 | img.save(output_buffer, format) 80 | byte_data = output_buffer.getvalue() 81 | base64_str = base64.b64encode(byte_data).decode() 82 | return 'base64://' + base64_str 83 | 84 | 85 | def load_data(data_path): 86 | try: 87 | with open(data_path, 'r', encoding='utf-8') as f: 88 | return json.load(f) 89 | except FileNotFoundError: 90 | return {} 91 | 92 | 93 | def save_game_data(data, data_path): 94 | with open(data_path, 'w', encoding='utf-8') as f: 95 | json.dump(data, f, ensure_ascii=False, indent=4) 96 | 97 | 98 | def record_game_success(user_id: int, group_id: int, game_type: str): 99 | '''记录各用户猜对的次数,作为排行榜''' 100 | gid = str(group_id) 101 | uid = str(user_id) 102 | data = load_game_data_json(gid) 103 | 104 | if data[gid]['rank'][game_type].get(uid) is None: 105 | data[gid]['rank'][game_type][uid] = 0 106 | data[gid]['rank'][game_type][uid] += 1 107 | save_game_data(data, game_data_path) 108 | 109 | 110 | def get_top_three(group_id: int, game_type: str): 111 | gid = str(group_id) 112 | data = load_game_data_json(gid) 113 | game_data = data[gid]['rank'][game_type] 114 | sorted_users = sorted(game_data.items(), key=lambda x: x[1], reverse=True) 115 | return sorted_users[:3] 116 | 117 | 118 | async def send_forward_message(bot: Bot, target_group_id, sender_id, origin_messages): 119 | '''将多条信息合并发出''' 120 | messages = [] 121 | for msg in origin_messages: 122 | if not msg: 123 | continue 124 | messages.append( 125 | MessageSegment.node_custom( 126 | user_id=sender_id, 127 | nickname="猜你字母bot", 128 | content=Message(msg) 129 | ) 130 | ) 131 | if len(messages) == 0: 132 | return 133 | try: 134 | # 该方法适用于拉格兰框架 135 | res_id = await bot.call_api("send_forward_msg", messages=messages) 136 | await bot.send_group_msg(group_id=target_group_id, message=Message(MessageSegment.forward(res_id))) 137 | except Exception as e: 138 | try: 139 | # 该方法适用于napcat框架 140 | res_id = await bot.call_api("send_forward_msg", group_id=target_group_id, messages=messages) 141 | except Exception as e2: 142 | logging.error(e2, exc_info=True) 143 | 144 | 145 | def load_game_data_json(gid: str): 146 | data = load_data(game_data_path) 147 | data.setdefault(gid, { 148 | "config": { 149 | "gauss": 10, 150 | "cut": 0.5, 151 | "shuffle": 0.1, 152 | "gray": 0.8, 153 | "transpose": False 154 | }, 155 | "rank": { 156 | "listen": {}, 157 | "open_character": {}, 158 | "cover": {}, 159 | "clue": {}, 160 | "chart": {}, 161 | "note": {} 162 | }, 163 | "game_enable": { 164 | "listen": True, 165 | "open_character": True, 166 | "cover": True, 167 | "clue": True, 168 | "chart": True, 169 | "random": True, 170 | "note": True 171 | } 172 | }) 173 | data[gid].setdefault('config', { 174 | "gauss": 10, 175 | "cut": 0.5, 176 | "shuffle": 0.1, 177 | "gray": 0.8, 178 | "transpose": False 179 | }) 180 | data[gid].setdefault('rank', { 181 | "listen": {}, 182 | "open_character": {}, 183 | "cover": {}, 184 | "clue": {}, 185 | "chart": {}, 186 | "note": {} 187 | }) 188 | data[gid].setdefault('game_enable', { 189 | "listen": True, 190 | "open_character": True, 191 | "cover": True, 192 | "clue": True, 193 | "chart": True, 194 | "random": True, 195 | "note": True 196 | }) 197 | data[gid]['config'].setdefault('gauss', 10) 198 | data[gid]['config'].setdefault('cut', 0.5) 199 | data[gid]['config'].setdefault('shuffle', 0.1) 200 | data[gid]['config'].setdefault('gray', 0.8) 201 | data[gid]['config'].setdefault('transpose', False) 202 | data[gid]['rank'].setdefault("listen", {}) 203 | data[gid]['rank'].setdefault("open_character", {}) 204 | data[gid]['rank'].setdefault("cover", {}) 205 | data[gid]['rank'].setdefault("clue", {}) 206 | data[gid]['rank'].setdefault("chart", {}) 207 | data[gid]['rank'].setdefault("note", {}) 208 | data[gid]['game_enable'].setdefault("listen", True) 209 | data[gid]['game_enable'].setdefault("open_character", True) 210 | data[gid]['game_enable'].setdefault("cover", True) 211 | data[gid]['game_enable'].setdefault("clue", True) 212 | data[gid]['game_enable'].setdefault("chart", True) 213 | data[gid]['game_enable'].setdefault("random", True) 214 | data[gid]['game_enable'].setdefault("note", True) 215 | return data 216 | 217 | async def isplayingcheck(gid, matcher: Matcher): 218 | gid = str(gid) 219 | if continuous_stop.get(gid) is not None: 220 | await matcher.finish(f"当前正在运行连续猜歌,可以发送\"停止\"来结束连续猜歌", reply_message=True) 221 | if gameplay_list.get(gid) is not None: 222 | now_playing_game = game_alias_map.get(list(gameplay_list.get(gid).keys())[0]) 223 | await matcher.finish(f"当前有一个{now_playing_game}正在运行,可以发送\"不玩了\"来结束游戏并公布答案", reply_message=True) 224 | 225 | 226 | def filter_random(data: list[Music], args: list[str], cnt: int = 1) -> Union[list[Music], None]: 227 | filters = {'other': []} 228 | flip_filters = {'other': []} 229 | for param in args: 230 | if not matchparam(param, filters): 231 | if param[0] != '-' or not matchparam(param[1:], flip_filters): 232 | return None 233 | data = list(filter(lambda x: x.genre != '宴会場', data)) 234 | for func in filters['other']: 235 | data = list(filter(func, data)) 236 | for func in flip_filters['other']: 237 | data = list(filter(lambda x: not func(x), data)) 238 | filters.pop('other') 239 | flip_filters.pop('other') 240 | for funcs in filters.values(): 241 | def total_func(x): 242 | result = False 243 | for func in funcs: 244 | result |= func(x) 245 | return result 246 | data = list(filter(total_func, data)) 247 | for funcs in flip_filters.values(): 248 | def total_flip_func(x): 249 | result = True 250 | for func in funcs: 251 | result &= not func(x) 252 | return result 253 | data = list(filter(total_flip_func, data)) 254 | try: 255 | result = random.sample(data, cnt) 256 | except ValueError: 257 | return None 258 | return result 259 | 260 | fault_tips = f'参数错误,可能是满足条件的歌曲数不足或格式错误,可用“/猜歌帮助”命令获取帮助哦!' 261 | 262 | def matchparam(param: str, filters: dict) -> bool: 263 | try: 264 | if _ := b50listb.get(param): #过滤参数 265 | filters['other'].append(_) 266 | 267 | elif _ := re.match(r'^版本([<>]?)(\=?)(.)$', param): 268 | if (ver := _.group(3)) not in plate_to_version: 269 | return False 270 | filters.setdefault('ver', []) 271 | ver = plate_to_version[labelmap.get(ver) or ver] 272 | if sign := _.group(1): 273 | if _.group(2): 274 | filter_func = lambda x: ver_filter(x, ver) 275 | filters['ver'].append(filter_func) 276 | for i, _ver in enumerate(verlist := list(plate_to_version.values())): 277 | if (sign == '>' and i > verlist.index(ver)) or (sign == '<' and i < verlist.index(ver)): 278 | filter_func = lambda x, __ver=_ver: ver_filter(x, __ver) 279 | filters['ver'].append(filter_func) 280 | else: 281 | filter_func = lambda x: ver_filter(x, ver) 282 | filters['ver'].append(filter_func) 283 | 284 | elif param[:2] == '分区': 285 | label = param[2:] 286 | if label not in category.values(): 287 | return False 288 | filter_func = lambda x: cat_filter(x, label) 289 | filters.setdefault('category', []) 290 | filters['category'].append(filter_func) 291 | 292 | elif param[:2] == '谱师': 293 | name = param[2:] 294 | if not (musicdata := find_charter(name)): 295 | return False 296 | filter_func = lambda x: charter_filter(x, musicdata) 297 | filters.setdefault('charter', []) 298 | filters['charter'].append(filter_func) 299 | 300 | elif _ := re.match(r'^等级([<>]?)(\=?)(.+)$', param): 301 | if (level := _.group(3)) not in levelList: 302 | return False 303 | filters.setdefault('level', []) 304 | if sign := _.group(1): 305 | if _.group(2): 306 | filter_func = lambda x: level_filter(x, level) 307 | filters['level'].append(filter_func) 308 | for i, _level in enumerate(levelList): 309 | if (sign == '>' and i > levelList.index(level)) or (sign == '<' and i < levelList.index(level)): 310 | filter_func = lambda x, lv=_level: level_filter(x, lv) 311 | filters['level'].append(filter_func) 312 | else: 313 | filter_func = lambda x: level_filter(x, level) 314 | filters['level'].append(filter_func) 315 | 316 | elif _ := re.match(r'^定数([<>]?)(\=?)(\d+\.\d)$', param): 317 | if not (1.0 <= (ds := float( _.group(3))) <= 15.0): 318 | return False 319 | filters.setdefault('ds', []) 320 | if sign := _.group(1): 321 | if _.group(2): 322 | filter_func = lambda x: ds_filter(x, ds) 323 | filters['ds'].append(filter_func) 324 | for i in range(10, 151): 325 | _ds = i / 10 326 | if (sign == '>' and _ds > ds) or (sign == '<' and _ds < ds): 327 | filter_func = lambda x, __ds=_ds: ds_filter(x, __ds) 328 | filters['ds'].append(filter_func) 329 | else: 330 | filter_func = lambda x: ds_filter(x, ds) 331 | filters['ds'].append(filter_func) 332 | 333 | else: 334 | return False 335 | except Exception as e: 336 | logging.error(e, exc_info=True) 337 | return False 338 | return True 339 | 340 | 341 | def ver_filter(music: Music, ver: str) -> bool: 342 | return music.version == ver 343 | 344 | def cat_filter(music: Music, cat: str) -> bool: 345 | genre = music.genre 346 | if category.get(genre): 347 | genre = category[genre] 348 | return genre == cat 349 | 350 | def charter_filter(music: Music, musiclist: list) -> bool: 351 | return music.id in musiclist 352 | 353 | 354 | def is_new(music: Music) -> bool: 355 | return music.is_new 356 | 357 | def is_old(music: Music) -> bool: 358 | return not music.is_new 359 | 360 | def is_sd(music: Music) -> bool: 361 | return music.type == 'SD' 362 | 363 | def is_dx(music: Music) -> bool: 364 | return music.type == 'DX' 365 | 366 | def level_filter(music: Music, level: str) -> bool: 367 | return level in music.level[3:] #只考虑紫白谱 368 | 369 | def ds_filter(music: Music, ds: float) -> bool: 370 | return ds in music.ds[3:] #只考虑紫白谱 371 | 372 | b50listb = { 373 | '新': is_new, 374 | '旧': is_old, 375 | 'sd': is_sd, 376 | 'dx': is_dx 377 | } 378 | 379 | def find_charter(name: str): 380 | all_music = list(filter(lambda x: x.genre != '宴会場', total_list.music_list)) 381 | white_music = list(filter(lambda x: len(x.charts) == 5, all_music)) 382 | music_data = [] 383 | for namelist in charterlist.values(): 384 | if name in namelist: 385 | for alias in namelist: 386 | music_data.extend(list(filter(lambda x: x.charts[3].charter == alias, all_music))) 387 | music_data.extend(list(filter(lambda x: x.charts[4].charter == alias, white_music))) 388 | if not len(music_data): 389 | music_data.extend(list(filter(lambda x: name.lower() in x.charts[3].charter.lower(), all_music))) 390 | music_data.extend(list(filter(lambda x: name.lower() in x.charts[4].charter.lower(), white_music))) 391 | return [music.id for music in music_data] 392 | 393 | def text_to_image(text: str) -> Image.Image: 394 | font = ImageFont.truetype(str(guess_static_resources_path / "SourceHanSansSC-Bold.otf"), 24) 395 | padding = 10 396 | margin = 4 397 | lines = text.strip().split('\n') 398 | max_width = 0 399 | b = 0 400 | for line in lines: 401 | l, t, r, b = font.getbbox(line) 402 | max_width = max(max_width, r) 403 | wa = max_width + padding * 2 404 | ha = b * len(lines) + margin * (len(lines) - 1) + padding * 2 405 | im = Image.new('RGB', (wa, ha), color=(255, 255, 255)) 406 | draw = ImageDraw.Draw(im) 407 | for index, line in enumerate(lines): 408 | draw.text((padding, padding + index * (margin + b)), line, font=font, fill=(0, 0, 0)) 409 | return im 410 | 411 | def to_bytes_io(text: str) -> BytesIO: 412 | bio = BytesIO() 413 | text_to_image(text).save(bio, format='PNG') 414 | bio.seek(0) 415 | return bio 416 | 417 | 418 | def seconds_to_hms(seconds: int) -> str: 419 | hours = int(seconds // 3600) 420 | minutes = int((seconds % 3600) // 60) 421 | secs = int(seconds % 60) 422 | return f"{hours:02}:{minutes:02}:{secs:02}" 423 | 424 | 425 | def get_video_duration(video_path): 426 | cmd = [ 427 | "ffprobe", 428 | "-v", "error", 429 | "-select_streams", "v:0", 430 | "-show_entries", "format=duration", 431 | "-of", "json", 432 | video_path 433 | ] 434 | result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) 435 | duration_info = json.loads(result.stdout) 436 | return int(float(duration_info["format"]["duration"])) if "format" in duration_info else None 437 | 438 | 439 | global_game_data = load_data(game_data_path) 440 | 441 | def check_game_disable(gid, game_name): 442 | global_game_data = load_game_data_json(gid) # 包含一个初始化 443 | enable_sign = global_game_data[gid]["game_enable"][game_name] 444 | return not enable_sign -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "nonebot-plugin-guess-song" 3 | version = "1.2.3" 4 | description = "A Guess song plugin for Nonebot2" 5 | authors = [ 6 | { name = "ssgXD", email = "554700172@qq.com" }, 7 | { name = "sab_tico", email = "3116295766@qq.com" }, 8 | { name = "WAduck", email = "1447671663@qq.com" }, 9 | { name = "qianorange", email = "2080396637@qq.com" } 10 | ] 11 | dependencies = [ 12 | "nonebot2>=2.3.0", 13 | "nonebot-adapter-onebot>=2.3.0", 14 | "nonebot-plugin-apscheduler>=0.5.0", 15 | "nonebot-plugin-localstore>=0.7.0", 16 | "pillow>=11.1.0", 17 | "pydub>=0.24.1,<0.26.0" 18 | ] 19 | requires-python = ">=3.9,<4.0" 20 | readme = "README.md" 21 | license = { text = "MIT" } 22 | 23 | keywords = ["nonebot2", "plugin", "maimaiDX", "舞萌DX", "guess", "song"] 24 | packages = [ 25 | { include = "nonebot_plugin_guess_song" } 26 | ] 27 | 28 | [project.urls] 29 | homepage = "https://github.com/apshuang/nonebot-plugin-guess-song" 30 | repository = "https://github.com/apshuang/nonebot-plugin-guess-song" 31 | documentation = "https://github.com/apshuang/nonebot-plugin-guess-song#readme" 32 | 33 | [tool.nonebot] 34 | adapters = [ 35 | { name = "OneBot V11", module_name = "nonebot.adapters.onebot.v11" } 36 | ] 37 | plugins = ["nonebot_plugin_guess_song"] 38 | 39 | [tool.pdm] 40 | [tool.pdm.build] 41 | includes = ["nonebot_plugin_guess_song"] 42 | 43 | [build-system] 44 | requires = ["pdm-backend"] 45 | build-backend = "pdm.backend" -------------------------------------------------------------------------------- /so_hard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apshuang/nonebot-plugin-guess-song/7bfc3ed440f5e982360d663872f4a3d509184392/so_hard.jpg --------------------------------------------------------------------------------