void commited on
Commit
163d304
1 Parent(s): 7f7db32

fork xfgryujk/blivedm

Browse files
blivedm/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ from .handlers import *
3
+ from .clients import *
blivedm/clients/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ from .web import *
3
+ from .open_live import *
blivedm/clients/open_live.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ import asyncio
3
+ import datetime
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import logging
8
+ import uuid
9
+ from typing import *
10
+
11
+ import aiohttp
12
+
13
+ from . import ws_base
14
+
15
+ __all__ = (
16
+ 'OpenLiveClient',
17
+ )
18
+
19
+ logger = logging.getLogger('blivedm')
20
+
21
+ START_URL = 'https://live-open.biliapi.com/v2/app/start'
22
+ HEARTBEAT_URL = 'https://live-open.biliapi.com/v2/app/heartbeat'
23
+ END_URL = 'https://live-open.biliapi.com/v2/app/end'
24
+
25
+
26
+ class OpenLiveClient(ws_base.WebSocketClientBase):
27
+ """
28
+ 开放平台客户端
29
+
30
+ 文档参考:https://open-live.bilibili.com/document/
31
+
32
+ :param access_key_id: 在开放平台申请的access_key_id
33
+ :param access_key_secret: 在开放平台申请的access_key_secret
34
+ :param app_id: 在开放平台创建的项目ID
35
+ :param room_owner_auth_code: 主播身份码
36
+ :param session: cookie、连接池
37
+ :param heartbeat_interval: 发送连接心跳包的间隔时间(秒)
38
+ :param game_heartbeat_interval: 发送项目心跳包的间隔时间(秒)
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ access_key_id: str,
44
+ access_key_secret: str,
45
+ app_id: int,
46
+ room_owner_auth_code: str,
47
+ *,
48
+ session: Optional[aiohttp.ClientSession] = None,
49
+ heartbeat_interval=30,
50
+ game_heartbeat_interval=20,
51
+ ):
52
+ super().__init__(session, heartbeat_interval)
53
+
54
+ self._access_key_id = access_key_id
55
+ self._access_key_secret = access_key_secret
56
+ self._app_id = app_id
57
+ self._room_owner_auth_code = room_owner_auth_code
58
+ self._game_heartbeat_interval = game_heartbeat_interval
59
+
60
+ # 在调用init_room后初始化的字段
61
+ self._room_owner_uid: Optional[int] = None
62
+ """主播用户ID"""
63
+ self._room_owner_open_id: Optional[str] = None
64
+ """主播Open ID"""
65
+ self._host_server_url_list: Optional[List[str]] = []
66
+ """弹幕服务器URL列表"""
67
+ self._auth_body: Optional[str] = None
68
+ """连接弹幕服务器用的认证包内容"""
69
+ self._game_id: Optional[str] = None
70
+ """项目场次ID"""
71
+
72
+ # 在运行时初始化的字段
73
+ self._game_heartbeat_timer_handle: Optional[asyncio.TimerHandle] = None
74
+ """发项目心跳包定时器的handle"""
75
+
76
+ @property
77
+ def room_owner_uid(self) -> Optional[int]:
78
+ """
79
+ 主播用户ID,调用init_room后初始化
80
+ """
81
+ return self._room_owner_uid
82
+
83
+ @property
84
+ def room_owner_open_id(self) -> Optional[str]:
85
+ """
86
+ 主播Open ID,调用init_room后初始化
87
+ """
88
+ return self._room_owner_open_id
89
+
90
+ @property
91
+ def room_owner_auth_code(self):
92
+ """
93
+ 主播身份码
94
+ """
95
+ return self._room_owner_auth_code
96
+
97
+ @property
98
+ def app_id(self):
99
+ """
100
+ 在开放平台创建的项目ID
101
+ """
102
+ return self._app_id
103
+
104
+ @property
105
+ def game_id(self) -> Optional[str]:
106
+ """
107
+ 项目场次ID,调用init_room后初始化
108
+ """
109
+ return self._game_id
110
+
111
+ async def close(self):
112
+ """
113
+ 释放本客户端的资源,调用后本客户端将不可用
114
+ """
115
+ if self.is_running:
116
+ logger.warning('room=%s is calling close(), but client is running', self.room_id)
117
+
118
+ if self._game_heartbeat_timer_handle is not None:
119
+ self._game_heartbeat_timer_handle.cancel()
120
+ self._game_heartbeat_timer_handle = None
121
+ await self._end_game()
122
+
123
+ await super().close()
124
+
125
+ def _request_open_live(self, url, body: dict):
126
+ body_bytes = json.dumps(body).encode('utf-8')
127
+ headers = {
128
+ 'x-bili-accesskeyid': self._access_key_id,
129
+ 'x-bili-content-md5': hashlib.md5(body_bytes).hexdigest(),
130
+ 'x-bili-signature-method': 'HMAC-SHA256',
131
+ 'x-bili-signature-nonce': uuid.uuid4().hex,
132
+ 'x-bili-signature-version': '1.0',
133
+ 'x-bili-timestamp': str(int(datetime.datetime.now().timestamp())),
134
+ }
135
+
136
+ str_to_sign = '\n'.join(
137
+ f'{key}:{value}'
138
+ for key, value in headers.items()
139
+ )
140
+ signature = hmac.new(
141
+ self._access_key_secret.encode('utf-8'), str_to_sign.encode('utf-8'), hashlib.sha256
142
+ ).hexdigest()
143
+ headers['Authorization'] = signature
144
+
145
+ headers['Content-Type'] = 'application/json'
146
+ headers['Accept'] = 'application/json'
147
+ return self._session.post(url, headers=headers, data=body_bytes)
148
+
149
+ async def init_room(self):
150
+ """
151
+ 开启项目,并初始化连接房间需要的字段
152
+
153
+ :return: 是否成功
154
+ """
155
+ if not await self._start_game():
156
+ return False
157
+
158
+ if self._game_id != '' and self._game_heartbeat_timer_handle is None:
159
+ self._game_heartbeat_timer_handle = asyncio.get_running_loop().call_later(
160
+ self._game_heartbeat_interval, self._on_send_game_heartbeat
161
+ )
162
+ return True
163
+
164
+ async def _start_game(self):
165
+ try:
166
+ async with self._request_open_live(
167
+ START_URL,
168
+ {'code': self._room_owner_auth_code, 'app_id': self._app_id}
169
+ ) as res:
170
+ if res.status != 200:
171
+ logger.warning('_start_game() failed, status=%d, reason=%s', res.status, res.reason)
172
+ return False
173
+ data = await res.json()
174
+ if data['code'] != 0:
175
+ logger.warning('_start_game() failed, code=%d, message=%s, request_id=%s',
176
+ data['code'], data['message'], data['request_id'])
177
+ return False
178
+ if not self._parse_start_game(data['data']):
179
+ return False
180
+ except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
181
+ logger.exception('_start_game() failed:')
182
+ return False
183
+ return True
184
+
185
+ def _parse_start_game(self, data):
186
+ self._game_id = data['game_info']['game_id']
187
+ websocket_info = data['websocket_info']
188
+ self._auth_body = websocket_info['auth_body']
189
+ self._host_server_url_list = websocket_info['wss_link']
190
+ anchor_info = data['anchor_info']
191
+ self._room_id = anchor_info['room_id']
192
+ self._room_owner_uid = anchor_info['uid']
193
+ self._room_owner_open_id = anchor_info['open_id']
194
+ return True
195
+
196
+ async def _end_game(self):
197
+ """
198
+ 关闭项目。建议关闭客户端时保证调用到这个函数(close会调用),否则可能短时间内无法重复连接同一个房间
199
+ """
200
+ if self._game_id in (None, ''):
201
+ return True
202
+
203
+ try:
204
+ async with self._request_open_live(
205
+ END_URL,
206
+ {'app_id': self._app_id, 'game_id': self._game_id}
207
+ ) as res:
208
+ if res.status != 200:
209
+ logger.warning('room=%d _end_game() failed, status=%d, reason=%s',
210
+ self._room_id, res.status, res.reason)
211
+ return False
212
+ data = await res.json()
213
+ code = data['code']
214
+ if code != 0:
215
+ if code in (7000, 7003):
216
+ # 项目已经关闭了也算成功
217
+ return True
218
+
219
+ logger.warning('room=%d _end_game() failed, code=%d, message=%s, request_id=%s',
220
+ self._room_id, code, data['message'], data['request_id'])
221
+ return False
222
+ except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
223
+ logger.exception('room=%d _end_game() failed:', self._room_id)
224
+ return False
225
+ return True
226
+
227
+ def _on_send_game_heartbeat(self):
228
+ """
229
+ 定时发送项目心跳包的回调
230
+ """
231
+ self._game_heartbeat_timer_handle = asyncio.get_running_loop().call_later(
232
+ self._game_heartbeat_interval, self._on_send_game_heartbeat
233
+ )
234
+ asyncio.create_task(self._send_game_heartbeat())
235
+
236
+ async def _send_game_heartbeat(self):
237
+ """
238
+ 发送项目心跳包
239
+ """
240
+ if self._game_id in (None, ''):
241
+ logger.warning('game=%d _send_game_heartbeat() failed, game_id not found', self._game_id)
242
+ return False
243
+
244
+ try:
245
+ # 保存一下,防止await之后game_id改变
246
+ game_id = self._game_id
247
+ async with self._request_open_live(
248
+ HEARTBEAT_URL,
249
+ {'game_id': game_id}
250
+ ) as res:
251
+ if res.status != 200:
252
+ logger.warning('room=%d _send_game_heartbeat() failed, status=%d, reason=%s',
253
+ self._room_id, res.status, res.reason)
254
+ return False
255
+ data = await res.json()
256
+ code = data['code']
257
+ if code != 0:
258
+ logger.warning('room=%d _send_game_heartbeat() failed, code=%d, message=%s, request_id=%s',
259
+ self._room_id, code, data['message'], data['request_id'])
260
+
261
+ if code == 7003 and self._game_id == game_id:
262
+ # 项目异常关闭,可能是心跳超时,需要重新开启项目
263
+ self._need_init_room = True
264
+ if self._websocket is not None and not self._websocket.closed:
265
+ await self._websocket.close()
266
+
267
+ return False
268
+ except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
269
+ logger.exception('room=%d _send_game_heartbeat() failed:', self._room_id)
270
+ return False
271
+ return True
272
+
273
+ async def _on_before_ws_connect(self, retry_count):
274
+ """
275
+ 在每次建立连接之前调用,可以用来初始化房间
276
+ """
277
+ # 重连次数太多则重新init_room,保险
278
+ reinit_period = max(3, len(self._host_server_url_list or ()))
279
+ if retry_count > 0 and retry_count % reinit_period == 0:
280
+ self._need_init_room = True
281
+ await super()._on_before_ws_connect(retry_count)
282
+
283
+ def _get_ws_url(self, retry_count) -> str:
284
+ """
285
+ 返回WebSocket连接的URL,可以在这里做故障转移和负载均衡
286
+ """
287
+ return self._host_server_url_list[retry_count % len(self._host_server_url_list)]
288
+
289
+ async def _send_auth(self):
290
+ """
291
+ 发送认证包
292
+ """
293
+ await self._websocket.send_bytes(self._make_packet(self._auth_body, ws_base.Operation.AUTH))
blivedm/clients/web.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ import asyncio
3
+ import logging
4
+ from typing import *
5
+
6
+ import aiohttp
7
+ import yarl
8
+
9
+ from . import ws_base
10
+ from .. import utils
11
+
12
+ __all__ = (
13
+ 'BLiveClient',
14
+ )
15
+
16
+ logger = logging.getLogger('blivedm')
17
+
18
+ UID_INIT_URL = 'https://api.bilibili.com/x/web-interface/nav'
19
+ BUVID_INIT_URL = 'https://data.bilibili.com/v/'
20
+ ROOM_INIT_URL = 'https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom'
21
+ DANMAKU_SERVER_CONF_URL = 'https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo'
22
+ DEFAULT_DANMAKU_SERVER_LIST = [
23
+ {'host': 'broadcastlv.chat.bilibili.com', 'port': 2243, 'wss_port': 443, 'ws_port': 2244}
24
+ ]
25
+
26
+
27
+ class BLiveClient(ws_base.WebSocketClientBase):
28
+ """
29
+ web端客户端
30
+
31
+ :param room_id: URL中的房间ID,可以用短ID
32
+ :param uid: B站用户ID,0表示未登录,None表示自动获取
33
+ :param session: cookie、连接池
34
+ :param heartbeat_interval: 发送心跳包的间隔时间(秒)
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ room_id: int,
40
+ *,
41
+ uid: Optional[int] = None,
42
+ session: Optional[aiohttp.ClientSession] = None,
43
+ heartbeat_interval=30,
44
+ ):
45
+ super().__init__(session, heartbeat_interval)
46
+
47
+ self._tmp_room_id = room_id
48
+ """用来init_room的临时房间ID,可以用短ID"""
49
+ self._uid = uid
50
+
51
+ # 在调用init_room后初始化的字段
52
+ self._room_owner_uid: Optional[int] = None
53
+ """主播用户ID"""
54
+ self._host_server_list: Optional[List[dict]] = None
55
+ """
56
+ 弹幕服务器列表
57
+
58
+ `[{host: "tx-bj4-live-comet-04.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244}, ...]`
59
+ """
60
+ self._host_server_token: Optional[str] = None
61
+ """连接弹幕服务器用的token"""
62
+
63
+ @property
64
+ def tmp_room_id(self) -> int:
65
+ """
66
+ 构造时传进来的room_id参数
67
+ """
68
+ return self._tmp_room_id
69
+
70
+ @property
71
+ def room_owner_uid(self) -> Optional[int]:
72
+ """
73
+ 主播用户ID,调用init_room后初始化
74
+ """
75
+ return self._room_owner_uid
76
+
77
+ @property
78
+ def uid(self) -> Optional[int]:
79
+ """
80
+ 当前登录的用户ID,未登录则为0,调用init_room后初始化
81
+ """
82
+ return self._uid
83
+
84
+ async def init_room(self):
85
+ """
86
+ 初始化连接房间需要的字段
87
+
88
+ :return: True代表没有降级,如果需要降级后还可用,重载这个函数返回True
89
+ """
90
+ if self._uid is None:
91
+ if not await self._init_uid():
92
+ logger.warning('room=%d _init_uid() failed', self._tmp_room_id)
93
+ self._uid = 0
94
+
95
+ if self._get_buvid() == '':
96
+ if not await self._init_buvid():
97
+ logger.warning('room=%d _init_buvid() failed', self._tmp_room_id)
98
+
99
+ res = True
100
+ if not await self._init_room_id_and_owner():
101
+ res = False
102
+ # 失败了则降级
103
+ self._room_id = self._tmp_room_id
104
+ self._room_owner_uid = 0
105
+
106
+ if not await self._init_host_server():
107
+ res = False
108
+ # 失败了则降级
109
+ self._host_server_list = DEFAULT_DANMAKU_SERVER_LIST
110
+ self._host_server_token = None
111
+ return res
112
+
113
+ async def _init_uid(self):
114
+ cookies = self._session.cookie_jar.filter_cookies(yarl.URL(UID_INIT_URL))
115
+ sessdata_cookie = cookies.get('SESSDATA', None)
116
+ if sessdata_cookie is None or sessdata_cookie.value == '':
117
+ # cookie都没有,不用请求了
118
+ self._uid = 0
119
+ return True
120
+
121
+ try:
122
+ async with self._session.get(
123
+ UID_INIT_URL,
124
+ headers={'User-Agent': utils.USER_AGENT},
125
+ ) as res:
126
+ if res.status != 200:
127
+ logger.warning('room=%d _init_uid() failed, status=%d, reason=%s', self._tmp_room_id,
128
+ res.status, res.reason)
129
+ return False
130
+ data = await res.json()
131
+ if data['code'] != 0:
132
+ if data['code'] == -101:
133
+ # 未登录
134
+ self._uid = 0
135
+ return True
136
+ logger.warning('room=%d _init_uid() failed, message=%s', self._tmp_room_id,
137
+ data['message'])
138
+ return False
139
+
140
+ data = data['data']
141
+ if not data['isLogin']:
142
+ # 未登录
143
+ self._uid = 0
144
+ else:
145
+ self._uid = data['mid']
146
+ return True
147
+ except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
148
+ logger.exception('room=%d _init_uid() failed:', self._tmp_room_id)
149
+ return False
150
+
151
+ def _get_buvid(self):
152
+ cookies = self._session.cookie_jar.filter_cookies(yarl.URL(BUVID_INIT_URL))
153
+ buvid_cookie = cookies.get('buvid3', None)
154
+ if buvid_cookie is None:
155
+ return ''
156
+ return buvid_cookie.value
157
+
158
+ async def _init_buvid(self):
159
+ try:
160
+ async with self._session.get(
161
+ BUVID_INIT_URL,
162
+ headers={'User-Agent': utils.USER_AGENT},
163
+ ) as res:
164
+ if res.status != 200:
165
+ logger.warning('room=%d _init_buvid() status error, status=%d, reason=%s',
166
+ self._tmp_room_id, res.status, res.reason)
167
+ except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
168
+ logger.exception('room=%d _init_buvid() exception:', self._tmp_room_id)
169
+ return self._get_buvid() != ''
170
+
171
+ async def _init_room_id_and_owner(self):
172
+ try:
173
+ async with self._session.get(
174
+ ROOM_INIT_URL,
175
+ headers={'User-Agent': utils.USER_AGENT},
176
+ params={
177
+ 'room_id': self._tmp_room_id
178
+ },
179
+ ) as res:
180
+ if res.status != 200:
181
+ logger.warning('room=%d _init_room_id_and_owner() failed, status=%d, reason=%s', self._tmp_room_id,
182
+ res.status, res.reason)
183
+ return False
184
+ data = await res.json()
185
+ if data['code'] != 0:
186
+ logger.warning('room=%d _init_room_id_and_owner() failed, message=%s', self._tmp_room_id,
187
+ data['message'])
188
+ return False
189
+ if not self._parse_room_init(data['data']):
190
+ return False
191
+ except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
192
+ logger.exception('room=%d _init_room_id_and_owner() failed:', self._tmp_room_id)
193
+ return False
194
+ return True
195
+
196
+ def _parse_room_init(self, data):
197
+ room_info = data['room_info']
198
+ self._room_id = room_info['room_id']
199
+ self._room_owner_uid = room_info['uid']
200
+ return True
201
+
202
+ async def _init_host_server(self):
203
+ try:
204
+ async with self._session.get(
205
+ DANMAKU_SERVER_CONF_URL,
206
+ headers={'User-Agent': utils.USER_AGENT},
207
+ params={
208
+ 'id': self._room_id,
209
+ 'type': 0
210
+ },
211
+ ) as res:
212
+ if res.status != 200:
213
+ logger.warning('room=%d _init_host_server() failed, status=%d, reason=%s', self._room_id,
214
+ res.status, res.reason)
215
+ return False
216
+ data = await res.json()
217
+ if data['code'] != 0:
218
+ logger.warning('room=%d _init_host_server() failed, message=%s', self._room_id, data['message'])
219
+ return False
220
+ if not self._parse_danmaku_server_conf(data['data']):
221
+ return False
222
+ except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
223
+ logger.exception('room=%d _init_host_server() failed:', self._room_id)
224
+ return False
225
+ return True
226
+
227
+ def _parse_danmaku_server_conf(self, data):
228
+ self._host_server_list = data['host_list']
229
+ self._host_server_token = data['token']
230
+ if not self._host_server_list:
231
+ logger.warning('room=%d _parse_danmaku_server_conf() failed: host_server_list is empty', self._room_id)
232
+ return False
233
+ return True
234
+
235
+ async def _on_before_ws_connect(self, retry_count):
236
+ """
237
+ 在每次建立连接之前调用,可以用来初始化房间
238
+ """
239
+ # 重连次数太多则重新init_room,保险
240
+ reinit_period = max(3, len(self._host_server_list or ()))
241
+ if retry_count > 0 and retry_count % reinit_period == 0:
242
+ self._need_init_room = True
243
+ await super()._on_before_ws_connect(retry_count)
244
+
245
+ def _get_ws_url(self, retry_count) -> str:
246
+ """
247
+ 返回WebSocket连接的URL,可以在这里做故障转移和负载均衡
248
+ """
249
+ host_server = self._host_server_list[retry_count % len(self._host_server_list)]
250
+ return f"wss://{host_server['host']}:{host_server['wss_port']}/sub"
251
+
252
+ async def _send_auth(self):
253
+ """
254
+ 发送认证包
255
+ """
256
+ auth_params = {
257
+ 'uid': self._uid,
258
+ 'roomid': self._room_id,
259
+ 'protover': 3,
260
+ 'platform': 'web',
261
+ 'type': 2,
262
+ 'buvid': self._get_buvid(),
263
+ }
264
+ if self._host_server_token is not None:
265
+ auth_params['key'] = self._host_server_token
266
+ await self._websocket.send_bytes(self._make_packet(auth_params, ws_base.Operation.AUTH))
blivedm/clients/ws_base.py ADDED
@@ -0,0 +1,494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ import asyncio
3
+ import enum
4
+ import json
5
+ import logging
6
+ import struct
7
+ import zlib
8
+ from typing import *
9
+
10
+ import aiohttp
11
+ import brotli
12
+
13
+ from .. import handlers, utils
14
+
15
+ logger = logging.getLogger('blivedm')
16
+
17
+ USER_AGENT = (
18
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36'
19
+ )
20
+
21
+ HEADER_STRUCT = struct.Struct('>I2H2I')
22
+
23
+
24
+ class HeaderTuple(NamedTuple):
25
+ pack_len: int
26
+ raw_header_size: int
27
+ ver: int
28
+ operation: int
29
+ seq_id: int
30
+
31
+
32
+ # WS_BODY_PROTOCOL_VERSION
33
+ class ProtoVer(enum.IntEnum):
34
+ NORMAL = 0
35
+ HEARTBEAT = 1
36
+ DEFLATE = 2
37
+ BROTLI = 3
38
+
39
+
40
+ # go-common\app\service\main\broadcast\model\operation.go
41
+ class Operation(enum.IntEnum):
42
+ HANDSHAKE = 0
43
+ HANDSHAKE_REPLY = 1
44
+ HEARTBEAT = 2
45
+ HEARTBEAT_REPLY = 3
46
+ SEND_MSG = 4
47
+ SEND_MSG_REPLY = 5
48
+ DISCONNECT_REPLY = 6
49
+ AUTH = 7
50
+ AUTH_REPLY = 8
51
+ RAW = 9
52
+ PROTO_READY = 10
53
+ PROTO_FINISH = 11
54
+ CHANGE_ROOM = 12
55
+ CHANGE_ROOM_REPLY = 13
56
+ REGISTER = 14
57
+ REGISTER_REPLY = 15
58
+ UNREGISTER = 16
59
+ UNREGISTER_REPLY = 17
60
+ # B站业务自定义OP
61
+ # MinBusinessOp = 1000
62
+ # MaxBusinessOp = 10000
63
+
64
+
65
+ # WS_AUTH
66
+ class AuthReplyCode(enum.IntEnum):
67
+ OK = 0
68
+ TOKEN_ERROR = -101
69
+
70
+
71
+ class InitError(Exception):
72
+ """初始化失败"""
73
+
74
+
75
+ class AuthError(Exception):
76
+ """认证失败"""
77
+
78
+
79
+ DEFAULT_RECONNECT_POLICY = utils.make_constant_retry_policy(1)
80
+
81
+
82
+ class WebSocketClientBase:
83
+ """
84
+ 基于WebSocket的客户端
85
+
86
+ :param session: cookie、连接池
87
+ :param heartbeat_interval: 发送心跳包的间隔时间(秒)
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ session: Optional[aiohttp.ClientSession] = None,
93
+ heartbeat_interval: float = 30,
94
+ ):
95
+ if session is None:
96
+ self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
97
+ self._own_session = True
98
+ else:
99
+ self._session = session
100
+ self._own_session = False
101
+ assert self._session.loop is asyncio.get_event_loop() # noqa
102
+
103
+ self._heartbeat_interval = heartbeat_interval
104
+
105
+ self._need_init_room = True
106
+ self._handler: Optional[handlers.HandlerInterface] = None
107
+ """消息处理器"""
108
+ self._get_reconnect_interval: Callable[[int, int], float] = DEFAULT_RECONNECT_POLICY
109
+ """重连间隔时间增长策略"""
110
+
111
+ # 在调用init_room后初始化的字段
112
+ self._room_id: Optional[int] = None
113
+
114
+ # 在运行时初始化的字段
115
+ self._websocket: Optional[aiohttp.ClientWebSocketResponse] = None
116
+ """WebSocket连接"""
117
+ self._network_future: Optional[asyncio.Future] = None
118
+ """网络协程的future"""
119
+ self._heartbeat_timer_handle: Optional[asyncio.TimerHandle] = None
120
+ """发心跳包定时器的handle"""
121
+
122
+ @property
123
+ def is_running(self) -> bool:
124
+ """
125
+ 本客户端正在运行,注意调用stop后还没完全停止也算正在运行
126
+ """
127
+ return self._network_future is not None
128
+
129
+ @property
130
+ def room_id(self) -> Optional[int]:
131
+ """
132
+ 房间ID,调用init_room后初始化
133
+ """
134
+ return self._room_id
135
+
136
+ def set_handler(self, handler: Optional['handlers.HandlerInterface']):
137
+ """
138
+ 设置消息处理器
139
+
140
+ 注意消息处理器和网络协程运行在同一个协程,如果处理消息耗时太长会阻塞接收消息。如果是CPU密集型的任务,建议将消息推到线程池处理;
141
+ 如果是IO密集型的任务,应该使用async函数,并且在handler里使用create_task创建新的协程
142
+
143
+ :param handler: 消息处理器
144
+ """
145
+ self._handler = handler
146
+
147
+ def set_reconnect_policy(self, get_reconnect_interval: Callable[[int, int], float]):
148
+ """
149
+ 设置重连间隔时间增长策略
150
+
151
+ :param get_reconnect_interval: 一个可调用对象,输入重试次数 (retry_count, total_retry_count),返回间隔时间
152
+ """
153
+ self._get_reconnect_interval = get_reconnect_interval
154
+
155
+ def start(self):
156
+ """
157
+ 启动本客户端
158
+ """
159
+ if self.is_running:
160
+ logger.warning('room=%s client is running, cannot start() again', self.room_id)
161
+ return
162
+
163
+ self._network_future = asyncio.create_task(self._network_coroutine_wrapper())
164
+
165
+ def stop(self):
166
+ """
167
+ 停止本客户端
168
+ """
169
+ if not self.is_running:
170
+ logger.warning('room=%s client is stopped, cannot stop() again', self.room_id)
171
+ return
172
+
173
+ self._network_future.cancel()
174
+
175
+ async def stop_and_close(self):
176
+ """
177
+ 便利函数,停止本客户端并释放本客户端的资源,调用后本客户端将不可用
178
+ """
179
+ if self.is_running:
180
+ self.stop()
181
+ await self.join()
182
+ await self.close()
183
+
184
+ async def join(self):
185
+ """
186
+ 等待本客户端停止
187
+ """
188
+ if not self.is_running:
189
+ logger.warning('room=%s client is stopped, cannot join()', self.room_id)
190
+ return
191
+
192
+ await asyncio.shield(self._network_future)
193
+
194
+ async def close(self):
195
+ """
196
+ 释放本客户端的资源,调用后本客户端将不可用
197
+ """
198
+ if self.is_running:
199
+ logger.warning('room=%s is calling close(), but client is running', self.room_id)
200
+
201
+ # 如果session是自己创建的则关闭session
202
+ if self._own_session:
203
+ await self._session.close()
204
+
205
+ async def init_room(self) -> bool:
206
+ """
207
+ 初始化连接房间需要的字段
208
+
209
+ :return: True代表没有降级,如果需要降级后还可用,重载这个函数返回True
210
+ """
211
+ raise NotImplementedError
212
+
213
+ @staticmethod
214
+ def _make_packet(data: Union[dict, str, bytes], operation: int) -> bytes:
215
+ """
216
+ 创建一个要发送给服务器的包
217
+
218
+ :param data: 包体JSON数据
219
+ :param operation: 操作码,见Operation
220
+ :return: 整个包的数据
221
+ """
222
+ if isinstance(data, dict):
223
+ body = json.dumps(data).encode('utf-8')
224
+ elif isinstance(data, str):
225
+ body = data.encode('utf-8')
226
+ else:
227
+ body = data
228
+ header = HEADER_STRUCT.pack(*HeaderTuple(
229
+ pack_len=HEADER_STRUCT.size + len(body),
230
+ raw_header_size=HEADER_STRUCT.size,
231
+ ver=1,
232
+ operation=operation,
233
+ seq_id=1
234
+ ))
235
+ return header + body
236
+
237
+ async def _network_coroutine_wrapper(self):
238
+ """
239
+ 负责处理网络协程的异常,网络协程具体逻辑在_network_coroutine里
240
+ """
241
+ exc = None
242
+ try:
243
+ await self._network_coroutine()
244
+ except asyncio.CancelledError:
245
+ # 正常停止
246
+ pass
247
+ except Exception as e:
248
+ logger.exception('room=%s _network_coroutine() finished with exception:', self.room_id)
249
+ exc = e
250
+ finally:
251
+ logger.debug('room=%s _network_coroutine() finished', self.room_id)
252
+ self._network_future = None
253
+
254
+ if self._handler is not None:
255
+ self._handler.on_client_stopped(self, exc)
256
+
257
+ async def _network_coroutine(self):
258
+ """
259
+ 网络协程,负责连接服务器、接收消息、解包
260
+ """
261
+ # retry_count在连接成功后会重置为0,total_retry_count不会
262
+ retry_count = 0
263
+ total_retry_count = 0
264
+ while True:
265
+ try:
266
+ await self._on_before_ws_connect(retry_count)
267
+
268
+ # 连接
269
+ async with self._session.ws_connect(
270
+ self._get_ws_url(retry_count),
271
+ headers={'User-Agent': utils.USER_AGENT}, # web端的token也会签名UA
272
+ receive_timeout=self._heartbeat_interval + 5,
273
+ ) as websocket:
274
+ self._websocket = websocket
275
+ await self._on_ws_connect()
276
+
277
+ # 处理消息
278
+ message: aiohttp.WSMessage
279
+ async for message in websocket:
280
+ await self._on_ws_message(message)
281
+ # 至少成功处理1条消息
282
+ retry_count = 0
283
+
284
+ except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
285
+ # 掉线重连
286
+ pass
287
+ except AuthError:
288
+ # 认证失败了,应该重新获取token再重连
289
+ logger.exception('room=%d auth failed, trying init_room() again', self.room_id)
290
+ self._need_init_room = True
291
+ finally:
292
+ self._websocket = None
293
+ await self._on_ws_close()
294
+
295
+ # 准备重连
296
+ retry_count += 1
297
+ total_retry_count += 1
298
+ logger.warning(
299
+ 'room=%d is reconnecting, retry_count=%d, total_retry_count=%d',
300
+ self.room_id, retry_count, total_retry_count
301
+ )
302
+ await asyncio.sleep(self._get_reconnect_interval(retry_count, total_retry_count))
303
+
304
+ async def _on_before_ws_connect(self, retry_count):
305
+ """
306
+ 在每次建立连接之前调用,可以用来初始化房间
307
+ """
308
+ if not self._need_init_room:
309
+ return
310
+
311
+ if not await self.init_room():
312
+ raise InitError('init_room() failed')
313
+ self._need_init_room = False
314
+
315
+ def _get_ws_url(self, retry_count) -> str:
316
+ """
317
+ 返回WebSocket连接的URL,可以在这里做故障转移和负载均衡
318
+ """
319
+ raise NotImplementedError
320
+
321
+ async def _on_ws_connect(self):
322
+ """
323
+ WebSocket连接成功
324
+ """
325
+ await self._send_auth()
326
+ self._heartbeat_timer_handle = asyncio.get_running_loop().call_later(
327
+ self._heartbeat_interval, self._on_send_heartbeat
328
+ )
329
+
330
+ async def _on_ws_close(self):
331
+ """
332
+ WebSocket连接断开
333
+ """
334
+ if self._heartbeat_timer_handle is not None:
335
+ self._heartbeat_timer_handle.cancel()
336
+ self._heartbeat_timer_handle = None
337
+
338
+ async def _send_auth(self):
339
+ """
340
+ 发送认证包
341
+ """
342
+ raise NotImplementedError
343
+
344
+ def _on_send_heartbeat(self):
345
+ """
346
+ 定时发送心跳包的回调
347
+ """
348
+ if self._websocket is None or self._websocket.closed:
349
+ self._heartbeat_timer_handle = None
350
+ return
351
+
352
+ self._heartbeat_timer_handle = asyncio.get_running_loop().call_later(
353
+ self._heartbeat_interval, self._on_send_heartbeat
354
+ )
355
+ asyncio.create_task(self._send_heartbeat())
356
+
357
+ async def _send_heartbeat(self):
358
+ """
359
+ 发送心跳包
360
+ """
361
+ if self._websocket is None or self._websocket.closed:
362
+ return
363
+
364
+ try:
365
+ await self._websocket.send_bytes(self._make_packet({}, Operation.HEARTBEAT))
366
+ except (ConnectionResetError, aiohttp.ClientConnectionError) as e:
367
+ logger.warning('room=%d _send_heartbeat() failed: %r', self.room_id, e)
368
+ except Exception: # noqa
369
+ logger.exception('room=%d _send_heartbeat() failed:', self.room_id)
370
+
371
+ async def _on_ws_message(self, message: aiohttp.WSMessage):
372
+ """
373
+ 收到WebSocket消息
374
+
375
+ :param message: WebSocket消息
376
+ """
377
+ if message.type != aiohttp.WSMsgType.BINARY:
378
+ logger.warning('room=%d unknown websocket message type=%s, data=%s', self.room_id,
379
+ message.type, message.data)
380
+ return
381
+
382
+ try:
383
+ await self._parse_ws_message(message.data)
384
+ except AuthError:
385
+ # 认证失败,让外层处理
386
+ raise
387
+ except Exception: # noqa
388
+ logger.exception('room=%d _parse_ws_message() error:', self.room_id)
389
+
390
+ async def _parse_ws_message(self, data: bytes):
391
+ """
392
+ 解析WebSocket消息
393
+
394
+ :param data: WebSocket消息数据
395
+ """
396
+ offset = 0
397
+ try:
398
+ header = HeaderTuple(*HEADER_STRUCT.unpack_from(data, offset))
399
+ except struct.error:
400
+ logger.exception('room=%d parsing header failed, offset=%d, data=%s', self.room_id, offset, data)
401
+ return
402
+
403
+ if header.operation in (Operation.SEND_MSG_REPLY, Operation.AUTH_REPLY):
404
+ # 业务消息,可能有多个包一起发,需要分包
405
+ while True:
406
+ body = data[offset + header.raw_header_size: offset + header.pack_len]
407
+ await self._parse_business_message(header, body)
408
+
409
+ offset += header.pack_len
410
+ if offset >= len(data):
411
+ break
412
+
413
+ try:
414
+ header = HeaderTuple(*HEADER_STRUCT.unpack_from(data, offset))
415
+ except struct.error:
416
+ logger.exception('room=%d parsing header failed, offset=%d, data=%s', self.room_id, offset, data)
417
+ break
418
+
419
+ elif header.operation == Operation.HEARTBEAT_REPLY:
420
+ # 服务器心跳包,前4字节是人气值,后面是客户端发的心跳包内容
421
+ # pack_len不包括客户端发的心跳包内容,不知道是不是服务器BUG
422
+ body = data[offset + header.raw_header_size: offset + header.raw_header_size + 4]
423
+ popularity = int.from_bytes(body, 'big')
424
+ # 自己造个消息当成业务消息处理
425
+ body = {
426
+ 'cmd': '_HEARTBEAT',
427
+ 'data': {
428
+ 'popularity': popularity
429
+ }
430
+ }
431
+ self._handle_command(body)
432
+
433
+ else:
434
+ # 未知消息
435
+ body = data[offset + header.raw_header_size: offset + header.pack_len]
436
+ logger.warning('room=%d unknown message operation=%d, header=%s, body=%s', self.room_id,
437
+ header.operation, header, body)
438
+
439
+ async def _parse_business_message(self, header: HeaderTuple, body: bytes):
440
+ """
441
+ 解析业务消息
442
+ """
443
+ if header.operation == Operation.SEND_MSG_REPLY:
444
+ # 业务消息
445
+ if header.ver == ProtoVer.BROTLI:
446
+ # 压缩过的先解压,为了避免阻塞网络线程,放在其他线程执行
447
+ body = await asyncio.get_running_loop().run_in_executor(None, brotli.decompress, body)
448
+ await self._parse_ws_message(body)
449
+ elif header.ver == ProtoVer.DEFLATE:
450
+ # web端已经不用zlib压缩了,但是开放平台会用
451
+ body = await asyncio.get_running_loop().run_in_executor(None, zlib.decompress, body)
452
+ await self._parse_ws_message(body)
453
+ elif header.ver == ProtoVer.NORMAL:
454
+ # 没压缩过的直接反序列化,因为有万恶的GIL,这里不能并行避免阻塞
455
+ if len(body) != 0:
456
+ try:
457
+ body = json.loads(body.decode('utf-8'))
458
+ self._handle_command(body)
459
+ except Exception:
460
+ logger.error('room=%d, body=%s', self.room_id, body)
461
+ raise
462
+ else:
463
+ # 未知格式
464
+ logger.warning('room=%d unknown protocol version=%d, header=%s, body=%s', self.room_id,
465
+ header.ver, header, body)
466
+
467
+ elif header.operation == Operation.AUTH_REPLY:
468
+ # 认证响应
469
+ body = json.loads(body.decode('utf-8'))
470
+ if body['code'] != AuthReplyCode.OK:
471
+ raise AuthError(f"auth reply error, code={body['code']}, body={body}")
472
+ await self._websocket.send_bytes(self._make_packet({}, Operation.HEARTBEAT))
473
+
474
+ else:
475
+ # 未知消息
476
+ logger.warning('room=%d unknown message operation=%d, header=%s, body=%s', self.room_id,
477
+ header.operation, header, body)
478
+
479
+ def _handle_command(self, command: dict):
480
+ """
481
+ 处理业务消息
482
+
483
+ :param command: 业务消息
484
+ """
485
+ if self._handler is None:
486
+ return
487
+ try:
488
+ # 为什么不做成异步的:
489
+ # 1. 为了保持处理消息的顺序,这里不使用call_soon、create_task等方法延迟处理
490
+ # 2. 如果支持handle使用async函数,用户可能会在里面处理耗时很长的异步操作,导致网络协程阻塞
491
+ # 这里做成同步的,强制用户使用create_task或消息队列处理异步操作,这样就不会阻塞网络协程
492
+ self._handler.handle(self, command)
493
+ except Exception as e:
494
+ logger.exception('room=%d _handle_command() failed, command=%s', self.room_id, command, exc_info=e)
blivedm/handlers.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ import logging
3
+ from typing import *
4
+
5
+ from .clients import ws_base
6
+ from .models import web as web_models, open_live as open_models
7
+
8
+ __all__ = (
9
+ 'HandlerInterface',
10
+ 'BaseHandler',
11
+ )
12
+
13
+ logger = logging.getLogger('blivedm')
14
+
15
+ logged_unknown_cmds = {
16
+ 'COMBO_SEND',
17
+ 'ENTRY_EFFECT',
18
+ 'HOT_RANK_CHANGED',
19
+ 'HOT_RANK_CHANGED_V2',
20
+ 'INTERACT_WORD',
21
+ 'LIVE',
22
+ 'LIVE_INTERACTIVE_GAME',
23
+ 'NOTICE_MSG',
24
+ 'ONLINE_RANK_COUNT',
25
+ 'ONLINE_RANK_TOP3',
26
+ 'ONLINE_RANK_V2',
27
+ 'PK_BATTLE_END',
28
+ 'PK_BATTLE_FINAL_PROCESS',
29
+ 'PK_BATTLE_PROCESS',
30
+ 'PK_BATTLE_PROCESS_NEW',
31
+ 'PK_BATTLE_SETTLE',
32
+ 'PK_BATTLE_SETTLE_USER',
33
+ 'PK_BATTLE_SETTLE_V2',
34
+ 'PREPARING',
35
+ 'ROOM_REAL_TIME_MESSAGE_UPDATE',
36
+ 'STOP_LIVE_ROOM_LIST',
37
+ 'SUPER_CHAT_MESSAGE_JPN',
38
+ 'WIDGET_BANNER',
39
+ }
40
+ """已打日志的未知cmd"""
41
+
42
+
43
+ class HandlerInterface:
44
+ """
45
+ 直播消息处理器接口
46
+ """
47
+
48
+ def handle(self, client: ws_base.WebSocketClientBase, command: dict):
49
+ raise NotImplementedError
50
+
51
+ def on_client_stopped(self, client: ws_base.WebSocketClientBase, exception: Optional[Exception]):
52
+ """
53
+ 当客户端停止时调用。可以在这里close或者重新start
54
+ """
55
+
56
+
57
+ def _make_msg_callback(method_name, message_cls):
58
+ def callback(self: 'BaseHandler', client: ws_base.WebSocketClientBase, command: dict):
59
+ method = getattr(self, method_name)
60
+ return method(client, message_cls.from_command(command['data']))
61
+ return callback
62
+
63
+
64
+ class BaseHandler(HandlerInterface):
65
+ """
66
+ 一个简单的消息处理器实现,带消息分发和消息类型转换。继承并重写_on_xxx方法即可实现自己的处理器
67
+ """
68
+
69
+ def __danmu_msg_callback(self, client: ws_base.WebSocketClientBase, command: dict):
70
+ return self._on_danmaku(client, web_models.DanmakuMessage.from_command(command['info']))
71
+
72
+ _CMD_CALLBACK_DICT: Dict[
73
+ str,
74
+ Optional[Callable[
75
+ ['BaseHandler', ws_base.WebSocketClientBase, dict],
76
+ Any
77
+ ]]
78
+ ] = {
79
+ # 收到心跳包,这是blivedm自造的消息,原本的心跳包格式不一样
80
+ '_HEARTBEAT': _make_msg_callback('_on_heartbeat', web_models.HeartbeatMessage),
81
+ # 收到弹幕
82
+ # go-common\app\service\live\live-dm\service\v1\send.go
83
+ 'DANMU_MSG': __danmu_msg_callback,
84
+ # 有人送礼
85
+ 'SEND_GIFT': _make_msg_callback('_on_gift', web_models.GiftMessage),
86
+ # 有人上舰
87
+ 'GUARD_BUY': _make_msg_callback('_on_buy_guard', web_models.GuardBuyMessage),
88
+ # 醒目留言
89
+ 'SUPER_CHAT_MESSAGE': _make_msg_callback('_on_super_chat', web_models.SuperChatMessage),
90
+ # 删除醒目留言
91
+ 'SUPER_CHAT_MESSAGE_DELETE': _make_msg_callback('_on_super_chat_delete', web_models.SuperChatDeleteMessage),
92
+
93
+ #
94
+ # 开放平台消息
95
+ #
96
+
97
+ # 收到弹幕
98
+ 'LIVE_OPEN_PLATFORM_DM': _make_msg_callback('_on_open_live_danmaku', open_models.DanmakuMessage),
99
+ # 有人送礼
100
+ 'LIVE_OPEN_PLATFORM_SEND_GIFT': _make_msg_callback('_on_open_live_gift', open_models.GiftMessage),
101
+ # 有人上舰
102
+ 'LIVE_OPEN_PLATFORM_GUARD': _make_msg_callback('_on_open_live_buy_guard', open_models.GuardBuyMessage),
103
+ # 醒目留言
104
+ 'LIVE_OPEN_PLATFORM_SUPER_CHAT': _make_msg_callback('_on_open_live_super_chat', open_models.SuperChatMessage),
105
+ # 删除醒目留言
106
+ 'LIVE_OPEN_PLATFORM_SUPER_CHAT_DEL': _make_msg_callback(
107
+ '_on_open_live_super_chat_delete', open_models.SuperChatDeleteMessage
108
+ ),
109
+ # 点赞
110
+ 'LIVE_OPEN_PLATFORM_LIKE': _make_msg_callback('_on_open_live_like', open_models.LikeMessage),
111
+ }
112
+ """cmd -> 处理回调"""
113
+
114
+ def handle(self, client: ws_base.WebSocketClientBase, command: dict):
115
+ cmd = command.get('cmd', '')
116
+ pos = cmd.find(':') # 2019-5-29 B站弹幕升级新增了参数
117
+ if pos != -1:
118
+ cmd = cmd[:pos]
119
+
120
+ if cmd not in self._CMD_CALLBACK_DICT:
121
+ # 只有第一次遇到未知cmd时打日志
122
+ if cmd not in logged_unknown_cmds:
123
+ logger.warning('room=%d unknown cmd=%s, command=%s', client.room_id, cmd, command)
124
+ logged_unknown_cmds.add(cmd)
125
+ return
126
+
127
+ callback = self._CMD_CALLBACK_DICT[cmd]
128
+ if callback is not None:
129
+ callback(self, client, command)
130
+
131
+ def _on_heartbeat(self, client: ws_base.WebSocketClientBase, message: web_models.HeartbeatMessage):
132
+ """
133
+ 收到心跳包
134
+ """
135
+
136
+ def _on_danmaku(self, client: ws_base.WebSocketClientBase, message: web_models.DanmakuMessage):
137
+ """
138
+ 收到弹幕
139
+ """
140
+
141
+ def _on_gift(self, client: ws_base.WebSocketClientBase, message: web_models.GiftMessage):
142
+ """
143
+ 收到礼物
144
+ """
145
+
146
+ def _on_buy_guard(self, client: ws_base.WebSocketClientBase, message: web_models.GuardBuyMessage):
147
+ """
148
+ 有人上舰
149
+ """
150
+
151
+ def _on_super_chat(self, client: ws_base.WebSocketClientBase, message: web_models.SuperChatMessage):
152
+ """
153
+ 醒目留言
154
+ """
155
+
156
+ def _on_super_chat_delete(
157
+ self, client: ws_base.WebSocketClientBase, message: web_models.SuperChatDeleteMessage
158
+ ):
159
+ """
160
+ 删除醒目留言
161
+ """
162
+
163
+ #
164
+ # 开放平台消息
165
+ #
166
+
167
+ def _on_open_live_danmaku(self, client: ws_base.WebSocketClientBase, message: open_models.DanmakuMessage):
168
+ """
169
+ 收到弹幕
170
+ """
171
+
172
+ def _on_open_live_gift(self, client: ws_base.WebSocketClientBase, message: open_models.GiftMessage):
173
+ """
174
+ 收到礼物
175
+ """
176
+
177
+ def _on_open_live_buy_guard(self, client: ws_base.WebSocketClientBase, message: open_models.GuardBuyMessage):
178
+ """
179
+ 有人上舰
180
+ """
181
+
182
+ def _on_open_live_super_chat(
183
+ self, client: ws_base.WebSocketClientBase, message: open_models.SuperChatMessage
184
+ ):
185
+ """
186
+ 醒目留言
187
+ """
188
+
189
+ def _on_open_live_super_chat_delete(
190
+ self, client: ws_base.WebSocketClientBase, message: open_models.SuperChatDeleteMessage
191
+ ):
192
+ """
193
+ 删除醒目留言
194
+ """
195
+
196
+ def _on_open_live_like(self, client: ws_base.WebSocketClientBase, message: open_models.LikeMessage):
197
+ """
198
+ 点赞
199
+ """
blivedm/models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # -*- coding: utf-8 -*-
blivedm/models/open_live.py ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ import dataclasses
3
+ from typing import *
4
+
5
+ __all__ = (
6
+ 'DanmakuMessage',
7
+ 'GiftMessage',
8
+ 'GuardBuyMessage',
9
+ 'SuperChatMessage',
10
+ 'SuperChatDeleteMessage',
11
+ 'LikeMessage',
12
+ )
13
+
14
+ # 注释都是复制自官方文档的,看不懂的话问B站
15
+ # https://open-live.bilibili.com/document/f9ce25be-312e-1f4a-85fd-fef21f1637f8
16
+
17
+
18
+ @dataclasses.dataclass
19
+ class DanmakuMessage:
20
+ """
21
+ 弹幕消息
22
+ """
23
+
24
+ uname: str = ''
25
+ """用户昵称"""
26
+ open_id: str = ''
27
+ """用户唯一标识"""
28
+ uface: str = ''
29
+ """用户头像"""
30
+ timestamp: int = 0
31
+ """弹幕发送时间秒级时间戳"""
32
+ room_id: int = 0
33
+ """弹幕接收的直播间"""
34
+ msg: str = ''
35
+ """弹幕内容"""
36
+ msg_id: str = ''
37
+ """消息唯一id"""
38
+ guard_level: int = 0
39
+ """对应房间大航海等级"""
40
+ fans_medal_wearing_status: bool = False
41
+ """该房间粉丝勋章佩戴情况"""
42
+ fans_medal_name: str = ''
43
+ """粉丝勋章名"""
44
+ fans_medal_level: int = 0
45
+ """对应房间勋章信息"""
46
+ emoji_img_url: str = ''
47
+ """表情包图片地址"""
48
+ dm_type: int = 0
49
+ """弹幕类型 0:普通弹幕 1:表情包弹幕"""
50
+
51
+ @classmethod
52
+ def from_command(cls, data: dict):
53
+ return cls(
54
+ uname=data['uname'],
55
+ open_id=data['open_id'],
56
+ uface=data['uface'],
57
+ timestamp=data['timestamp'],
58
+ room_id=data['room_id'],
59
+ msg=data['msg'],
60
+ msg_id=data['msg_id'],
61
+ guard_level=data['guard_level'],
62
+ fans_medal_wearing_status=data['fans_medal_wearing_status'],
63
+ fans_medal_name=data['fans_medal_name'],
64
+ fans_medal_level=data['fans_medal_level'],
65
+ emoji_img_url=data['emoji_img_url'],
66
+ dm_type=data['dm_type'],
67
+ )
68
+
69
+
70
+ @dataclasses.dataclass
71
+ class AnchorInfo:
72
+ """
73
+ 主播信息
74
+ """
75
+
76
+ uid: int = 0
77
+ """收礼主播uid"""
78
+ open_id: str = ''
79
+ """收礼主播唯一标识"""
80
+ uname: str = ''
81
+ """收礼主播昵称"""
82
+ uface: str = ''
83
+ """收礼主播头像"""
84
+
85
+ @classmethod
86
+ def from_dict(cls, data: dict):
87
+ return cls(
88
+ uid=data['uid'],
89
+ open_id=data['open_id'],
90
+ uname=data['uname'],
91
+ uface=data['uface'],
92
+ )
93
+
94
+
95
+ @dataclasses.dataclass
96
+ class ComboInfo:
97
+ """
98
+ 连击信息
99
+ """
100
+
101
+ combo_base_num: int = 0
102
+ """每次连击赠送的道具数量"""
103
+ combo_count: int = 0
104
+ """连击次数"""
105
+ combo_id: str = ''
106
+ """连击id"""
107
+ combo_timeout: int = 0
108
+ """连击有效期秒"""
109
+
110
+ @classmethod
111
+ def from_dict(cls, data: dict):
112
+ return cls(
113
+ combo_base_num=data['combo_base_num'],
114
+ combo_count=data['combo_count'],
115
+ combo_id=data['combo_id'],
116
+ combo_timeout=data['combo_timeout'],
117
+ )
118
+
119
+
120
+ @dataclasses.dataclass
121
+ class GiftMessage:
122
+ """
123
+ 礼物消息
124
+ """
125
+
126
+ room_id: int = 0
127
+ """房间号"""
128
+ open_id: str = ''
129
+ """用户唯一标识"""
130
+ uname: str = ''
131
+ """送礼用户昵称"""
132
+ uface: str = ''
133
+ """送礼用户头像"""
134
+ gift_id: int = 0
135
+ """道具id(盲盒:爆出道具id)"""
136
+ gift_name: str = ''
137
+ """道具名(盲盒:爆出道具名)"""
138
+ gift_num: int = 0
139
+ """赠送道具数量"""
140
+ price: int = 0
141
+ """礼物爆出单价,(1000 = 1元 = 10电池),盲盒:爆出道具的价值"""
142
+ paid: bool = False
143
+ """是否是付费道具"""
144
+ fans_medal_level: int = 0
145
+ """实际送礼人的勋章信息"""
146
+ fans_medal_name: str = ''
147
+ """粉丝勋章名"""
148
+ fans_medal_wearing_status: bool = False
149
+ """该房间粉丝勋章佩戴情况"""
150
+ guard_level: int = 0
151
+ """大航海等级"""
152
+ timestamp: int = 0
153
+ """收礼时间秒级时间戳"""
154
+ anchor_info: AnchorInfo = dataclasses.field(default_factory=AnchorInfo)
155
+ """主播信息"""
156
+ msg_id: str = ''
157
+ """消息唯一id"""
158
+ gift_icon: str = ''
159
+ """道具icon"""
160
+ combo_gift: bool = False
161
+ """是否是combo道具"""
162
+ combo_info: ComboInfo = dataclasses.field(default_factory=ComboInfo)
163
+ """连击信息"""
164
+
165
+ @classmethod
166
+ def from_command(cls, data: dict):
167
+ combo_info = data.get('combo_info', None)
168
+ if combo_info is None:
169
+ combo_info = ComboInfo()
170
+ else:
171
+ combo_info = ComboInfo.from_dict(combo_info)
172
+
173
+ return cls(
174
+ room_id=data['room_id'],
175
+ open_id=data['open_id'],
176
+ uname=data['uname'],
177
+ uface=data['uface'],
178
+ gift_id=data['gift_id'],
179
+ gift_name=data['gift_name'],
180
+ gift_num=data['gift_num'],
181
+ price=data['price'],
182
+ paid=data['paid'],
183
+ fans_medal_level=data['fans_medal_level'],
184
+ fans_medal_name=data['fans_medal_name'],
185
+ fans_medal_wearing_status=data['fans_medal_wearing_status'],
186
+ guard_level=data['guard_level'],
187
+ timestamp=data['timestamp'],
188
+ anchor_info=AnchorInfo.from_dict(data['anchor_info']),
189
+ msg_id=data['msg_id'],
190
+ gift_icon=data['gift_icon'],
191
+ combo_gift=data.get('combo_gift', False), # 官方的调试工具没发这个字段
192
+ combo_info=combo_info, # 官方的调试工具没发这个字段
193
+ )
194
+
195
+
196
+ @dataclasses.dataclass
197
+ class UserInfo:
198
+ """
199
+ 用户信息
200
+ """
201
+
202
+ open_id: str = ''
203
+ """用户唯一标识"""
204
+ uname: str = ''
205
+ """用户昵称"""
206
+ uface: str = ''
207
+ """用户头像"""
208
+
209
+ @classmethod
210
+ def from_dict(cls, data: dict):
211
+ return cls(
212
+ open_id=data['open_id'],
213
+ uname=data['uname'],
214
+ uface=data['uface'],
215
+ )
216
+
217
+
218
+ @dataclasses.dataclass
219
+ class GuardBuyMessage:
220
+ """
221
+ 上舰消息
222
+ """
223
+
224
+ user_info: UserInfo = dataclasses.field(default_factory=UserInfo)
225
+ """用户信息"""
226
+ guard_level: int = 0
227
+ """大航海等级"""
228
+ guard_num: int = 0
229
+ """大航海数量"""
230
+ guard_unit: str = ''
231
+ """大航海单位"""
232
+ price: int = 0
233
+ """大航海金瓜子"""
234
+ fans_medal_level: int = 0
235
+ """粉丝勋章等级"""
236
+ fans_medal_name: str = ''
237
+ """粉丝勋章名"""
238
+ fans_medal_wearing_status: bool = False
239
+ """该房间粉丝勋章佩戴情况"""
240
+ room_id: int = 0
241
+ """房间号"""
242
+ msg_id: str = ''
243
+ """消息唯一id"""
244
+ timestamp: int = 0
245
+ """上舰时间秒级时间戳"""
246
+
247
+ @classmethod
248
+ def from_command(cls, data: dict):
249
+ return cls(
250
+ user_info=UserInfo.from_dict(data['user_info']),
251
+ guard_level=data['guard_level'],
252
+ guard_num=data['guard_num'],
253
+ guard_unit=data['guard_unit'],
254
+ price=data['price'],
255
+ fans_medal_level=data['fans_medal_level'],
256
+ fans_medal_name=data['fans_medal_name'],
257
+ fans_medal_wearing_status=data['fans_medal_wearing_status'],
258
+ room_id=data['room_id'],
259
+ msg_id=data['msg_id'],
260
+ timestamp=data['timestamp'],
261
+ )
262
+
263
+
264
+ @dataclasses.dataclass
265
+ class SuperChatMessage:
266
+ """
267
+ 醒目留言消息
268
+ """
269
+
270
+ room_id: int = 0
271
+ """直播间id"""
272
+ open_id: str = ''
273
+ """用户唯一标识"""
274
+ uname: str = ''
275
+ """购买的用户昵称"""
276
+ uface: str = ''
277
+ """购买用户头像"""
278
+ message_id: int = 0
279
+ """留言id(风控场景下撤回留言需要)"""
280
+ message: str = ''
281
+ """留言内容"""
282
+ rmb: int = 0
283
+ """支付金额(元)"""
284
+ timestamp: int = 0
285
+ """赠送时间秒级"""
286
+ start_time: int = 0
287
+ """生效开始时间"""
288
+ end_time: int = 0
289
+ """生效结束时间"""
290
+ guard_level: int = 0
291
+ """对应房间大航海等级"""
292
+ fans_medal_level: int = 0
293
+ """对应房间勋章信息"""
294
+ fans_medal_name: str = ''
295
+ """对应房间勋章名字"""
296
+ fans_medal_wearing_status: bool = False
297
+ """该房间粉丝勋章佩戴情况"""
298
+ msg_id: str = ''
299
+ """消息唯一id"""
300
+
301
+ @classmethod
302
+ def from_command(cls, data: dict):
303
+ return cls(
304
+ room_id=data['room_id'],
305
+ open_id=data['open_id'],
306
+ uname=data['uname'],
307
+ uface=data['uface'],
308
+ message_id=data['message_id'],
309
+ message=data['message'],
310
+ rmb=data['rmb'],
311
+ timestamp=data['timestamp'],
312
+ start_time=data['start_time'],
313
+ end_time=data['end_time'],
314
+ guard_level=data['guard_level'],
315
+ fans_medal_level=data['fans_medal_level'],
316
+ fans_medal_name=data['fans_medal_name'],
317
+ fans_medal_wearing_status=data['fans_medal_wearing_status'],
318
+ msg_id=data['msg_id'],
319
+ )
320
+
321
+
322
+ @dataclasses.dataclass
323
+ class SuperChatDeleteMessage:
324
+ """
325
+ 删除醒目留言消息
326
+ """
327
+
328
+ room_id: int = 0
329
+ """直播间id"""
330
+ message_ids: List[int] = dataclasses.field(default_factory=list)
331
+ """留言id"""
332
+ msg_id: str = ''
333
+ """消息唯一id"""
334
+
335
+ @classmethod
336
+ def from_command(cls, data: dict):
337
+ return cls(
338
+ room_id=data['room_id'],
339
+ message_ids=data['message_ids'],
340
+ msg_id=data['msg_id'],
341
+ )
342
+
343
+
344
+ @dataclasses.dataclass
345
+ class LikeMessage:
346
+ """
347
+ 点赞消息
348
+
349
+ 请注意:用户端每分钟触发若干次的情况下只会推送一次该消息
350
+ """
351
+
352
+ uname: str = ''
353
+ """用户昵称"""
354
+ open_id: str = ''
355
+ """用户唯一标识"""
356
+ uface: str = ''
357
+ """用户头像"""
358
+ timestamp: int = 0
359
+ """时间秒级时间戳"""
360
+ room_id: int = 0
361
+ """发生的直播间"""
362
+ like_text: str = ''
363
+ """点赞文案(“xxx点赞了”)"""
364
+ like_count: int = 0
365
+ """对单个用户最近2秒的点赞次数聚合"""
366
+ fans_medal_wearing_status: bool = False
367
+ """该房间粉丝勋章佩戴情况"""
368
+ fans_medal_name: str = ''
369
+ """粉丝勋章名"""
370
+ fans_medal_level: int = 0
371
+ """对应房间勋章信息"""
372
+ msg_id: str = '' # 官方文档表格里没列出这个字段,但是参考JSON里面有
373
+ """消息唯一id"""
374
+ # 还有个guard_level,但官方文档没有出现这个字段,就不添加了
375
+
376
+ @classmethod
377
+ def from_command(cls, data: dict):
378
+ return cls(
379
+ uname=data['uname'],
380
+ open_id=data['open_id'],
381
+ uface=data['uface'],
382
+ timestamp=data['timestamp'],
383
+ room_id=data['room_id'],
384
+ like_text=data['like_text'],
385
+ like_count=data['like_count'],
386
+ fans_medal_wearing_status=data['fans_medal_wearing_status'],
387
+ fans_medal_name=data['fans_medal_name'],
388
+ fans_medal_level=data['fans_medal_level'],
389
+ msg_id=data.get('msg_id', ''), # 官方文档表格里没列出这个字段,但是参考JSON里面有
390
+ )
blivedm/models/web.py ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ import dataclasses
3
+ import json
4
+ from typing import *
5
+
6
+ __all__ = (
7
+ 'HeartbeatMessage',
8
+ 'DanmakuMessage',
9
+ 'GiftMessage',
10
+ 'GuardBuyMessage',
11
+ 'SuperChatMessage',
12
+ 'SuperChatDeleteMessage',
13
+ )
14
+
15
+
16
+ @dataclasses.dataclass
17
+ class HeartbeatMessage:
18
+ """
19
+ 心跳消息
20
+ """
21
+
22
+ popularity: int = 0
23
+ """人气值,已废弃"""
24
+
25
+ @classmethod
26
+ def from_command(cls, data: dict):
27
+ return cls(
28
+ popularity=data['popularity'],
29
+ )
30
+
31
+
32
+ @dataclasses.dataclass
33
+ class DanmakuMessage:
34
+ """
35
+ 弹幕消息
36
+ """
37
+
38
+ mode: int = 0
39
+ """弹幕显示模式(滚动、顶部、底部)"""
40
+ font_size: int = 0
41
+ """字体尺寸"""
42
+ color: int = 0
43
+ """颜色"""
44
+ timestamp: int = 0
45
+ """时间戳(毫秒)"""
46
+ rnd: int = 0
47
+ """随机数,前端叫作弹幕ID,可能是去重用的"""
48
+ uid_crc32: str = ''
49
+ """用户ID文本的CRC32"""
50
+ msg_type: int = 0
51
+ """是否礼物弹幕(节奏风暴)"""
52
+ bubble: int = 0
53
+ """右侧评论栏气泡"""
54
+ dm_type: int = 0
55
+ """弹幕类型,0文本,1表情,2语音"""
56
+ emoticon_options: Union[dict, str] = ''
57
+ """表情参数"""
58
+ voice_config: Union[dict, str] = ''
59
+ """语音参数"""
60
+ mode_info: dict = dataclasses.field(default_factory=dict)
61
+ """一些附加参数"""
62
+
63
+ msg: str = ''
64
+ """弹幕内容"""
65
+
66
+ uid: int = 0
67
+ """用户ID"""
68
+ uname: str = ''
69
+ """用户名"""
70
+ admin: int = 0
71
+ """是否房管"""
72
+ vip: int = 0
73
+ """是否月费老爷"""
74
+ svip: int = 0
75
+ """是否年费老爷"""
76
+ urank: int = 0
77
+ """用户身份,用来判断是否正式会员,猜测非正式会员为5000,正式会员为10000"""
78
+ mobile_verify: int = 0
79
+ """是否绑定手机"""
80
+ uname_color: str = ''
81
+ """用户名颜色"""
82
+
83
+ medal_level: str = ''
84
+ """勋章等级"""
85
+ medal_name: str = ''
86
+ """勋章名"""
87
+ runame: str = ''
88
+ """勋章房间主播名"""
89
+ medal_room_id: int = 0
90
+ """勋章房间ID"""
91
+ mcolor: int = 0
92
+ """勋章颜色"""
93
+ special_medal: str = ''
94
+ """特殊勋章"""
95
+
96
+ user_level: int = 0
97
+ """用户等级"""
98
+ ulevel_color: int = 0
99
+ """用户等级颜色"""
100
+ ulevel_rank: str = ''
101
+ """用户等级排名,>50000时为'>50000'"""
102
+
103
+ old_title: str = ''
104
+ """旧头衔"""
105
+ title: str = ''
106
+ """头衔"""
107
+
108
+ privilege_type: int = 0
109
+ """舰队类型,0非舰队,1总督,2提督,3舰长"""
110
+
111
+ @classmethod
112
+ def from_command(cls, info: list):
113
+ if len(info[3]) != 0:
114
+ medal_level = info[3][0]
115
+ medal_name = info[3][1]
116
+ runame = info[3][2]
117
+ room_id = info[3][3]
118
+ mcolor = info[3][4]
119
+ special_medal = info[3][5]
120
+ else:
121
+ medal_level = 0
122
+ medal_name = ''
123
+ runame = ''
124
+ room_id = 0
125
+ mcolor = 0
126
+ special_medal = 0
127
+
128
+ if len(info[5]) != 0:
129
+ old_title = info[5][0]
130
+ title = info[5][1]
131
+ else:
132
+ old_title = ''
133
+ title = ''
134
+
135
+ return cls(
136
+ mode=info[0][1],
137
+ font_size=info[0][2],
138
+ color=info[0][3],
139
+ timestamp=info[0][4],
140
+ rnd=info[0][5],
141
+ uid_crc32=info[0][7],
142
+ msg_type=info[0][9],
143
+ bubble=info[0][10],
144
+ dm_type=info[0][12],
145
+ emoticon_options=info[0][13],
146
+ voice_config=info[0][14],
147
+ mode_info=info[0][15],
148
+
149
+ msg=info[1],
150
+
151
+ uid=info[2][0],
152
+ uname=info[2][1],
153
+ admin=info[2][2],
154
+ vip=info[2][3],
155
+ svip=info[2][4],
156
+ urank=info[2][5],
157
+ mobile_verify=info[2][6],
158
+ uname_color=info[2][7],
159
+
160
+ medal_level=medal_level,
161
+ medal_name=medal_name,
162
+ runame=runame,
163
+ medal_room_id=room_id,
164
+ mcolor=mcolor,
165
+ special_medal=special_medal,
166
+
167
+ user_level=info[4][0],
168
+ ulevel_color=info[4][2],
169
+ ulevel_rank=info[4][3],
170
+
171
+ old_title=old_title,
172
+ title=title,
173
+
174
+ privilege_type=info[7],
175
+ )
176
+
177
+ @property
178
+ def emoticon_options_dict(self) -> dict:
179
+ """
180
+ 示例:
181
+ {'bulge_display': 0, 'emoticon_unique': 'official_13', 'height': 60, 'in_player_area': 1, 'is_dynamic': 1,
182
+ 'url': 'https://i0.hdslb.com/bfs/live/a98e35996545509188fe4d24bd1a56518ea5af48.png', 'width': 183}
183
+ """
184
+ if isinstance(self.emoticon_options, dict):
185
+ return self.emoticon_options
186
+ try:
187
+ return json.loads(self.emoticon_options)
188
+ except (json.JSONDecodeError, TypeError):
189
+ return {}
190
+
191
+ @property
192
+ def voice_config_dict(self) -> dict:
193
+ """
194
+ 示例:
195
+ {'voice_url': 'https%3A%2F%2Fboss.hdslb.com%2Flive-dm-voice%2Fb5b26e48b556915cbf3312a59d3bb2561627725945.wav
196
+ %3FX-Amz-Algorithm%3DAWS4-HMAC-SHA256%26X-Amz-Credential%3D2663ba902868f12f%252F20210731%252Fshjd%252Fs3%25
197
+ 2Faws4_request%26X-Amz-Date%3D20210731T100545Z%26X-Amz-Expires%3D600000%26X-Amz-SignedHeaders%3Dhost%26
198
+ X-Amz-Signature%3D114e7cb5ac91c72e231c26d8ca211e53914722f36309b861a6409ffb20f07ab8',
199
+ 'file_format': 'wav', 'text': '汤,下午好。', 'file_duration': 1}
200
+ """
201
+ if isinstance(self.voice_config, dict):
202
+ return self.voice_config
203
+ try:
204
+ return json.loads(self.voice_config)
205
+ except (json.JSONDecodeError, TypeError):
206
+ return {}
207
+
208
+
209
+ @dataclasses.dataclass
210
+ class GiftMessage:
211
+ """
212
+ 礼物消息
213
+ """
214
+
215
+ gift_name: str = ''
216
+ """礼物名"""
217
+ num: int = 0
218
+ """数量"""
219
+ uname: str = ''
220
+ """用户名"""
221
+ face: str = ''
222
+ """用户头像URL"""
223
+ guard_level: int = 0
224
+ """舰队等级,0非舰队,1总督,2提督,3舰长"""
225
+ uid: int = 0
226
+ """用户ID"""
227
+ timestamp: int = 0
228
+ """时间戳"""
229
+ gift_id: int = 0
230
+ """礼物ID"""
231
+ gift_type: int = 0
232
+ """礼物类型(未知)"""
233
+ action: str = ''
234
+ """目前遇到的有'喂食'、'赠送'"""
235
+ price: int = 0
236
+ """礼物单价瓜子数"""
237
+ rnd: str = ''
238
+ """随机数,可能是去重用的。有时是时间戳+去重ID,有时是UUID"""
239
+ coin_type: str = ''
240
+ """瓜子类型,'silver'或'gold',1000金瓜子 = 1元"""
241
+ total_coin: int = 0
242
+ """总瓜子数"""
243
+ tid: str = ''
244
+ """可能是事务ID,有时和rnd相同"""
245
+
246
+ @classmethod
247
+ def from_command(cls, data: dict):
248
+ return cls(
249
+ gift_name=data['giftName'],
250
+ num=data['num'],
251
+ uname=data['uname'],
252
+ face=data['face'],
253
+ guard_level=data['guard_level'],
254
+ uid=data['uid'],
255
+ timestamp=data['timestamp'],
256
+ gift_id=data['giftId'],
257
+ gift_type=data['giftType'],
258
+ action=data['action'],
259
+ price=data['price'],
260
+ rnd=data['rnd'],
261
+ coin_type=data['coin_type'],
262
+ total_coin=data['total_coin'],
263
+ tid=data['tid'],
264
+ )
265
+
266
+
267
+ @dataclasses.dataclass
268
+ class GuardBuyMessage:
269
+ """
270
+ 上舰消息
271
+ """
272
+
273
+ uid: int = 0
274
+ """用户ID"""
275
+ username: str = ''
276
+ """用户名"""
277
+ guard_level: int = 0
278
+ """舰队等级,0非舰队,1总督,2提督,3舰长"""
279
+ num: int = 0
280
+ """数量"""
281
+ price: int = 0
282
+ """单价金瓜子数"""
283
+ gift_id: int = 0
284
+ """礼物ID"""
285
+ gift_name: str = ''
286
+ """礼物名"""
287
+ start_time: int = 0
288
+ """开始时间戳,和结束时间戳相同"""
289
+ end_time: int = 0
290
+ """结束时间戳,和开始时间戳相同"""
291
+
292
+ @classmethod
293
+ def from_command(cls, data: dict):
294
+ return cls(
295
+ uid=data['uid'],
296
+ username=data['username'],
297
+ guard_level=data['guard_level'],
298
+ num=data['num'],
299
+ price=data['price'],
300
+ gift_id=data['gift_id'],
301
+ gift_name=data['gift_name'],
302
+ start_time=data['start_time'],
303
+ end_time=data['end_time'],
304
+ )
305
+
306
+
307
+ @dataclasses.dataclass
308
+ class SuperChatMessage:
309
+ """
310
+ 醒目留言消息
311
+ """
312
+
313
+ price: int = 0
314
+ """价格(人民币)"""
315
+ message: str = ''
316
+ """消息"""
317
+ message_trans: str = ''
318
+ """消息日文翻译(目前只出现在SUPER_CHAT_MESSAGE_JPN)"""
319
+ start_time: int = 0
320
+ """开始时间戳"""
321
+ end_time: int = 0
322
+ """结束时间戳"""
323
+ time: int = 0
324
+ """剩余时间(约等于 结束时间戳 - 开始时间戳)"""
325
+ id: int = 0
326
+ """醒目留言ID,删除时用"""
327
+ gift_id: int = 0
328
+ """礼物ID"""
329
+ gift_name: str = ''
330
+ """礼物名"""
331
+ uid: int = 0
332
+ """用户ID"""
333
+ uname: str = ''
334
+ """用户名"""
335
+ face: str = ''
336
+ """用户头像URL"""
337
+ guard_level: int = 0
338
+ """舰队等级,0非舰队,1总督,2提督,3舰长"""
339
+ user_level: int = 0
340
+ """用户等级"""
341
+ background_bottom_color: str = ''
342
+ """底部背景色,'#rrggbb'"""
343
+ background_color: str = ''
344
+ """背景色,'#rrggbb'"""
345
+ background_icon: str = ''
346
+ """背景图标"""
347
+ background_image: str = ''
348
+ """背景图URL"""
349
+ background_price_color: str = ''
350
+ """背景价格颜色,'#rrggbb'"""
351
+
352
+ @classmethod
353
+ def from_command(cls, data: dict):
354
+ return cls(
355
+ price=data['price'],
356
+ message=data['message'],
357
+ message_trans=data['message_trans'],
358
+ start_time=data['start_time'],
359
+ end_time=data['end_time'],
360
+ time=data['time'],
361
+ id=data['id'],
362
+ gift_id=data['gift']['gift_id'],
363
+ gift_name=data['gift']['gift_name'],
364
+ uid=data['uid'],
365
+ uname=data['user_info']['uname'],
366
+ face=data['user_info']['face'],
367
+ guard_level=data['user_info']['guard_level'],
368
+ user_level=data['user_info']['user_level'],
369
+ background_bottom_color=data['background_bottom_color'],
370
+ background_color=data['background_color'],
371
+ background_icon=data['background_icon'],
372
+ background_image=data['background_image'],
373
+ background_price_color=data['background_price_color'],
374
+ )
375
+
376
+
377
+ @dataclasses.dataclass
378
+ class SuperChatDeleteMessage:
379
+ """
380
+ 删除醒目留言消息
381
+ """
382
+
383
+ ids: List[int] = dataclasses.field(default_factory=list)
384
+ """醒目留言ID数组"""
385
+
386
+ @classmethod
387
+ def from_command(cls, data: dict):
388
+ return cls(
389
+ ids=data['ids'],
390
+ )
blivedm/utils.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ USER_AGENT = (
3
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36'
4
+ )
5
+
6
+
7
+ def make_constant_retry_policy(interval: float):
8
+ def get_interval(_retry_count: int, _total_retry_count: int):
9
+ return interval
10
+ return get_interval
11
+
12
+
13
+ def make_linear_retry_policy(start_interval: float, interval_step: float, max_interval: float):
14
+ def get_interval(retry_count: int, _total_retry_count: int):
15
+ return min(
16
+ start_interval + (retry_count - 1) * interval_step,
17
+ max_interval
18
+ )
19
+ return get_interval