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