├── 1.json ├── LICENSE ├── README.md ├── voice.py └── voice_example.py /1.json: -------------------------------------------------------------------------------- 1 | { 2 | "1": { 3 | "request": true, 4 | "id": 1000000, 5 | "method": "getRouterRtpCapabilities", 6 | "data": { 7 | 8 | } 9 | }, 10 | "2": { 11 | "data": { 12 | "displayName": "" 13 | }, 14 | "id": 1000000, 15 | "method": "join", 16 | "request": true 17 | }, 18 | "3": { 19 | "data": { 20 | "comedia": true, 21 | "rtcpMux": false, 22 | "type": "plain" 23 | }, 24 | "id": 1000000, 25 | "method": "createPlainTransport", 26 | "request": true 27 | }, 28 | "4": { 29 | "data": { 30 | "appData": { 31 | 32 | }, 33 | "kind": "audio", 34 | "peerId": "", 35 | "rtpParameters": { 36 | "codecs": [ 37 | { 38 | "channels": 2, 39 | "clockRate": 48000, 40 | "mimeType": "audio/opus", 41 | "parameters": { 42 | "sprop-stereo": 1 43 | }, 44 | "payloadType": 100 45 | } 46 | ], 47 | "encodings": [ 48 | { 49 | "ssrc": 1357 50 | } 51 | ] 52 | }, 53 | "transportId": "" 54 | }, 55 | "id": 1000000, 56 | "method": "produce", 57 | "request": true 58 | } 59 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 hank9999 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 开黑啦 V3 语音API 2 | ## KOOK 官方现已提供官方极简API 本项目归档 3 | 本项目提供 API 使用说明 及 Python 异步实现 4 | 5 | ## 使用须知 6 | **注意本 API 由抓包得来,API 可能会随时变动进而失效** 7 | **您需要得知使用此API会违反 [开黑啦语音软件许可及服务协议](https://www.kaiheila.cn/protocol.html) `3.2.3` 或 `3.2.5` 条款** 8 | **同时会违反 [开黑啦开发者隐私政策](https://developer.kaiheila.cn/doc/privacy) `数据信息` 或 `滥用` 中的相关条款** 9 | 10 | ## API 说明 11 | gateway: `https://www.kaiheila.cn/api/v3/gateway/voice?channel_id={channel_id}` 12 | 先向 `gateway` 请求 `ws` 链接 13 | 然后取到 `ws` 链接之后,依次发 `1.json` 中的 1,2,3,4 14 | 发了3号之后,把返回的数据存起来, 里面的 `id` 要填到4号的 `transportId` 中 15 | 3号返回的data中 有rtp推流用的 `ip` `port` `rtcpPort` 16 | 每一号都需要生成一个随机数id 用随机int替代 17 | `ws` 记得发协议的保活 `Ping` `ws`掉了语音自动掉 18 | 实现参考 voice_example.py 19 | 多推直接引用详细说明 和 单推直接引用说明 见下文 20 | 21 | ## 多推说明 22 | 通过 `API` 请求另一个服务器的语音频道 且 保持上一个语音频道的 `ws` 连接, 可实现同时进多个不同服务器的频道 且 可以推送不同的音频流 23 | 24 | ## Python voice.py 多推 直接引用详细说明 25 | 26 | ```python 27 | import asyncio 28 | from voice import Voice 29 | 30 | playlist: Dict[str, List[Dict]] = {} 31 | # playlist = {'群组ID': [{'channel': '音频频道ID', 'file': '音频文件路径'}]} 32 | playlist_handle_status = {} 33 | 34 | 35 | # playlist_handle_status = {'群组ID': True} 某个群组已有处理器 36 | 37 | # 新建线程处理对应群组的播放列表 38 | class PlayHandler(threading.Thread): 39 | def __init__(self, guild: str): 40 | threading.Thread.__init__(self) 41 | self.guild = guild 42 | self.voice = Voice(token) 43 | 44 | def run(self): 45 | print("开始处理:" + self.guild) 46 | loop_t = asyncio.new_event_loop() 47 | loop_t.run_until_complete(self.main()) 48 | print("处理完成:" + self.guild) 49 | 50 | async def main(self): 51 | task_handler = asyncio.create_task(self.handler()) 52 | task_voice_handler = asyncio.create_task(self.voice.handler()) 53 | await asyncio.wait([ 54 | task_handler, # 播放列表 处理 55 | task_voice_handler # voice 处理 56 | ], return_when='FIRST_COMPLETED') 57 | 58 | async def handler(self): 59 | global playlist_handle_status 60 | while True: 61 | if len(playlist[self.guild]) != 0: 62 | play_info = playlist[self.guild].pop(0) # 取出一个 63 | self.voice.channel_id = play_info['channel'] # 设置 voice 频道ID 64 | while True: 65 | if len(self.voice.rtp_url) != 0: 66 | rtp_url = self.voice.rtp_url # 获取 rtp 推流链接 67 | break 68 | await asyncio.sleep(0.1) 69 | audio_path = play_info['file'] # 获取文件路径 70 | # 开始推流 71 | command = f'ffmpeg -re -loglevel level+info -nostats -i "{audio_path}" -map 0:a:0 -acodec libopus -ab 128k -filter:a volume=0.8 -ac 2 -ar 48000 -f tee [select=a:f=rtp:ssrc=1357:payload_type=100]{rtp_url}' 72 | p = await asyncio.create_subprocess_shell(command, shell=True, stdout=subprocess.DEVNULL, 73 | stderr=subprocess.DEVNULL) 74 | while True: 75 | # 判断当前歌曲是否推流结束 76 | if p.returncode is not None: 77 | subprocess.Popen("TASKKILL /F /PID {pid} /T".format(pid=p.pid), stdout=subprocess.DEVNULL, 78 | stderr=subprocess.DEVNULL) 79 | # voice 结束当前频道 80 | self.voice.is_exit = True 81 | while True: 82 | if not self.voice.is_exit: 83 | break 84 | await asyncio.sleep(0.1) 85 | break 86 | await asyncio.sleep(0.1) 87 | else: 88 | # 播放列表为空 退出处理器 89 | playlist_handle_status[self.guild] = False 90 | return 91 | await asyncio.sleep(0.1) 92 | 93 | 94 | async def playlist_handler(): 95 | while True: 96 | for guild in playlist.keys(): 97 | # playlist_handle_status 用于判断群组是否已有处理器 没有则新建线程处理 98 | if guild not in playlist_handle_status and len(playlist[guild]) != 0: 99 | playlist_handle_status[guild] = True 100 | PlayHandler(guild).start() 101 | if guild in playlist_handle_status and not playlist_handle_status[guild] and len(playlist[guild]) != 0: 102 | playlist_handle_status[guild] = True 103 | PlayHandler(guild).start() 104 | await asyncio.sleep(0.1) 105 | 106 | 107 | async def main(): 108 | task_playlist_handler = asyncio.create_task(playlist_handler()) 109 | task_bot = asyncio.create_task(bot.start()) 110 | await asyncio.wait([ 111 | task_playlist_handler, # 播放列表处理 112 | task_bot # khl.py 框架 113 | ]) 114 | 115 | 116 | if __name__ == '__main__': 117 | loop = asyncio.get_event_loop() 118 | loop.run_until_complete(main()) 119 | ``` 120 | 121 | ## Python voice.py 单推 直接引用说明 122 | 123 | ```python 124 | import asyncio 125 | from voice import Voice 126 | 127 | 128 | async def playlist_handler(): 129 | ... # handler 实现 130 | 131 | # 加入频道 132 | voice.channel_id = '频道 ID' 133 | while True: 134 | if len(voice.rtp_url) != 0: 135 | rtp_url = voice.rtp_url 136 | break 137 | await asyncio.sleep(0.1) 138 | 139 | ... # 推流实现 140 | 141 | # 结束当前推流 142 | voice.is_exit = True 143 | while True: 144 | if not voice.is_exit: 145 | break 146 | await asyncio.sleep(0.1) 147 | 148 | 149 | async def main(): 150 | task_playlist_handler = asyncio.create_task(playlist_handler()) 151 | task_bot = asyncio.create_task(bot.start()) 152 | task_voice_handler = asyncio.create_task(voice.handler()) 153 | await asyncio.wait([ 154 | task_playlist_handler, # 播放列表处理 155 | task_bot, # khl.py 框架 156 | task_voice_handler # voice 处理 157 | ]) 158 | 159 | 160 | if __name__ == '__main__': 161 | voice = Voice(token) # 初始化 voice, token 为机器人 token 162 | loop = asyncio.get_event_loop() 163 | loop.run_until_complete(main()) 164 | ``` 165 | 166 | ## 推流说明 167 | ``` 168 | ffmpeg推流 169 | ffmpeg -re -loglevel level+info -nostats -i "xxxxx.mp3" -map 0:a:0 -acodec libopus -ab 128k -filter:a volume=0.8 -ac 2 -ar 48000 -f tee [select=a:f=rtp:ssrc=1357:payload_type=100]rtp://xxxx 170 | ``` 171 | 172 | ``` 173 | 用ffmpeg zmq可以实现切歌不掉 174 | ffmpeg -re -nostats -i "xxx.mp3" -acodec libopus -ab 128k -f mpegts zmq:tcp://127.0.0.1:1234 175 | 176 | ffmpeg -re -loglevel level+info -nostats -stream_loop -1 -i zmq:tcp://127.0.0.1:1234 -map 0:a:0 -acodec libopus -ab 128k -filter:a volume=0.8 -ac 2 -ar 48000 -f tee [select=a:f=rtp:ssrc=1357:payload_type=100]rtp://xxxx 177 | ``` 178 | -------------------------------------------------------------------------------- /voice.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | from typing import List 4 | import aiohttp 5 | import json 6 | import asyncio 7 | from aiohttp import ClientWebSocketResponse 8 | 9 | 10 | class Voice: 11 | token = '' 12 | channel_id = '' 13 | rtp_url = '' 14 | ws_clients: List[ClientWebSocketResponse] = [] 15 | wait_handler_msgs = [] 16 | is_exit = False 17 | 18 | def __init__(self, token: str): 19 | self.token = token 20 | self.ws_clients = [] 21 | self.wait_handler_msgs = [] 22 | 23 | async def get_gateway(self, channel_id: str) -> str: 24 | async with aiohttp.ClientSession() as session: 25 | async with session.get(f'https://www.kaiheila.cn/api/v3/gateway/voice?channel_id={channel_id}', 26 | headers={'Authorization': f'Bot {self.token}'}) as res: 27 | print(await res.text()) 28 | return (await res.json())['data']['gateway_url'] 29 | 30 | async def connect_ws(self): 31 | gateway = await self.get_gateway(self.channel_id) 32 | print(gateway) 33 | async with aiohttp.ClientSession() as session: 34 | async with session.ws_connect(gateway) as ws: 35 | self.ws_clients.append(ws) 36 | async for msg in ws: 37 | if msg.type == aiohttp.WSMsgType.TEXT: 38 | if len(self.ws_clients) != 0 and self.ws_clients[0] == ws: 39 | self.wait_handler_msgs.append(msg.data) 40 | elif msg.type == aiohttp.WSMsgType.ERROR: 41 | break 42 | else: 43 | return 44 | 45 | async def ws_msg(self): 46 | while True: 47 | if self.is_exit: 48 | return 49 | if len(self.ws_clients) != 0: 50 | break 51 | await asyncio.sleep(0.1) 52 | with open('1.json', 'r') as f: 53 | a = json.loads(f.read()) 54 | a['1']['id'] = random.randint(1000000, 9999999) 55 | print('1:', a['1']) 56 | await self.ws_clients[0].send_json(a['1']) 57 | now = 1 58 | ip = '' 59 | port = 0 60 | rtcp_port = 0 61 | while True: 62 | if self.is_exit: 63 | return 64 | if len(self.wait_handler_msgs) != 0: 65 | data = json.loads(self.wait_handler_msgs.pop(0)) 66 | if now == 1: 67 | print('1:', data) 68 | a['2']['id'] = random.randint(1000000, 9999999) 69 | print('2:', a['2']) 70 | await self.ws_clients[0].send_json(a['2']) 71 | now = 2 72 | elif now == 2: 73 | print('2:', data) 74 | a['3']['id'] = random.randint(1000000, 9999999) 75 | print('3:', a['3']) 76 | await self.ws_clients[0].send_json(a['3']) 77 | now = 3 78 | elif now == 3: 79 | print('3:', data) 80 | transport_id = data['data']['id'] 81 | ip = data['data']['ip'] 82 | port = data['data']['port'] 83 | rtcp_port = data['data']['rtcpPort'] 84 | a['4']['data']['transportId'] = transport_id 85 | a['4']['id'] = random.randint(1000000, 9999999) 86 | print('4:', a['4']) 87 | await self.ws_clients[0].send_json(a['4']) 88 | now = 4 89 | elif now == 4: 90 | print('4:', data) 91 | print(f'ssrc=1357 ffmpeg rtp url: rtp://{ip}:{port}?rtcpport={rtcp_port}') 92 | self.rtp_url = f'rtp://{ip}:{port}?rtcpport={rtcp_port}' 93 | now = 5 94 | else: 95 | if 'notification' in data and 'method' in data and data['method'] == 'disconnect': 96 | print('The connection had been disconnected', data) 97 | else: 98 | print('else:', data) 99 | await asyncio.sleep(0.1) 100 | 101 | async def ws_ping(self): 102 | while True: 103 | if self.is_exit: 104 | return 105 | if len(self.ws_clients) != 0: 106 | break 107 | await asyncio.sleep(0.1) 108 | ping_time = 0.0 109 | while True: 110 | if self.is_exit: 111 | return 112 | await asyncio.sleep(0.1) 113 | if len(self.ws_clients) == 0: 114 | return 115 | now_time = time.time() 116 | if now_time - ping_time >= 30: 117 | await self.ws_clients[0].ping() 118 | ping_time = now_time 119 | 120 | async def main(self): 121 | task_ws_msg = asyncio.create_task(self.ws_msg()) 122 | task_connect_ws = asyncio.create_task(self.connect_ws()) 123 | task_ws_ping = asyncio.create_task(self.ws_ping()) 124 | await asyncio.wait([task_ws_msg, task_connect_ws, task_ws_ping], return_when='FIRST_COMPLETED') 125 | if len(self.ws_clients) != 0: 126 | await self.ws_clients[0].close() 127 | self.is_exit = False 128 | self.channel_id = '' 129 | self.rtp_url = '' 130 | self.ws_clients.clear() 131 | self.wait_handler_msgs.clear() 132 | 133 | async def handler(self): 134 | while True: 135 | if len(self.channel_id) != 0: 136 | await self.main() 137 | await asyncio.sleep(0.1) 138 | -------------------------------------------------------------------------------- /voice_example.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | from typing import List 4 | import aiohttp 5 | import json 6 | import asyncio 7 | from aiohttp import ClientWebSocketResponse 8 | 9 | 10 | token = 'token' 11 | bot_id = '机器人ID' 12 | channel_id = '语音频道ID' 13 | 14 | 15 | ws_clients: List[ClientWebSocketResponse] = [] 16 | wait_handler_msgs = [] 17 | with open('1.json', 'r') as f: 18 | a = json.loads(f.read()) 19 | 20 | async def get_gateway(channel_id: str) -> str: 21 | async with aiohttp.ClientSession() as session: 22 | async with session.get(f'https://www.kaiheila.cn/api/v3/gateway/voice?channel_id={channel_id}', 23 | headers={'Authorization': f'Bot {token}'}) as res: 24 | return (await res.json())['data']['gateway_url'] 25 | 26 | 27 | async def connect_ws(): 28 | global ws_clients 29 | gateway = await get_gateway(channel_id) 30 | print(gateway) 31 | async with aiohttp.ClientSession() as session: 32 | async with session.ws_connect(gateway) as ws: 33 | ws_clients.append(ws) 34 | async for msg in ws: 35 | if msg.type == aiohttp.WSMsgType.TEXT: 36 | wait_handler_msgs.append(msg.data) 37 | elif msg.type == aiohttp.WSMsgType.ERROR: 38 | break 39 | else: 40 | return 41 | 42 | 43 | async def ws_msg(): 44 | while True: 45 | if len(ws_clients) != 0: 46 | break 47 | await asyncio.sleep(0.1) 48 | a['1']['id'] = random.randint(1000000, 9999999) 49 | print('1:', a['1']) 50 | await ws_clients[0].send_json(a['1']) 51 | now = 1 52 | ip = '' 53 | port = 0 54 | rtcp_port = 0 55 | while True: 56 | if len(wait_handler_msgs) != 0: 57 | data = json.loads(wait_handler_msgs.pop(0)) 58 | if now == 1: 59 | print('1:', data) 60 | a['2']['id'] = random.randint(1000000, 9999999) 61 | print('2:', a['2']) 62 | await ws_clients[0].send_json(a['2']) 63 | now = 2 64 | elif now == 2: 65 | print('2:', data) 66 | a['3']['id'] = random.randint(1000000, 9999999) 67 | print('3:', a['3']) 68 | await ws_clients[0].send_json(a['3']) 69 | now = 3 70 | elif now == 3: 71 | print('3:', data) 72 | transport_id = data['data']['id'] 73 | ip = data['data']['ip'] 74 | port = data['data']['port'] 75 | rtcp_port = data['data']['rtcpPort'] 76 | a['4']['data']['transportId'] = transport_id 77 | a['4']['id'] = random.randint(1000000, 9999999) 78 | print('4:', a['4']) 79 | await ws_clients[0].send_json(a['4']) 80 | now = 4 81 | elif now == 4: 82 | print('4:', data) 83 | print(f'ssrc=1357 ffmpeg rtp url: rtp://{ip}:{port}?rtcpport={rtcp_port}') 84 | now = 5 85 | else: 86 | if 'notification' in data and 'method' in data and data['method'] == 'disconnect': 87 | print('The connection had been disconnected', data) 88 | elif 'notification' in data and 'method' in data and data['method'] == 'networkStat' and bot_id in data['data']['stat']: 89 | # await (ws_clients.pop(0)).close() 90 | # return 91 | pass 92 | else: 93 | print(data) 94 | continue 95 | await asyncio.sleep(0.1) 96 | 97 | 98 | async def ws_ping(): 99 | while True: 100 | if len(ws_clients) != 0: 101 | break 102 | await asyncio.sleep(0.1) 103 | ping_time = 0.0 104 | while True: 105 | await asyncio.sleep(0.1) 106 | if len(ws_clients) == 0: 107 | return 108 | now_time = time.time() 109 | if now_time - ping_time >= 30: 110 | await ws_clients[0].ping() 111 | ping_time = now_time 112 | 113 | 114 | async def main(): 115 | while True: 116 | await asyncio.gather( 117 | connect_ws(), 118 | ws_msg(), 119 | ws_ping() 120 | ) 121 | 122 | 123 | if __name__ == '__main__': 124 | asyncio.run(main()) --------------------------------------------------------------------------------