Compare commits

..

31 Commits

Author SHA1 Message Date
John Smith
60efe1d55b 更新版本号v1.8.2 2023-09-23 10:07:48 +08:00
John Smith
b08906c150 补上房间语言配置 2023-09-21 23:52:54 +08:00
John Smith
c3b573baf6 缓解房间URL太长,被直播姬截断的问题 2023-09-21 23:37:58 +08:00
John Smith
dae5f782db 额外通过内容来判断是否是礼物弹幕 2023-09-20 22:50:50 +08:00
John Smith
39ab15e1ac 修复开放平台接口礼物显示的是单价而不是总价的问题 2023-09-18 20:08:08 +08:00
John Smith
49fe186dc4 更新版本号v1.8.1 2023-09-17 09:09:22 +08:00
John Smith
a11de39166 修复后端重连时报错 2023-09-16 23:01:45 +08:00
John Smith
dccb3fc890 降级身份码错误的日志 2023-09-16 12:23:01 +08:00
John Smith
ef7f0dc0ac 获取头像失败时尝试用用户名随机生成 2023-09-16 11:31:46 +08:00
John Smith
b1916608ee 前端当网页不可见时,延迟加载房间 2023-09-15 23:18:47 +08:00
John Smith
7e656ebe09 重连间隔时间可增长,防止一直重连失败时消耗太多资源 2023-09-15 22:47:17 +08:00
John Smith
be05184777 完善前端获取身份码的教程,做成弱智保姆软件 2023-09-14 23:01:11 +08:00
John Smith
63ef4ac971 缓存错误的身份码,避免旧版的请求传到开放平台 2023-09-13 00:45:44 +08:00
John Smith
05fc89dbbe 前端在身份码错误时跳到帮助页面,不再重试连接 2023-09-13 00:13:11 +08:00
John Smith
8bfceb4b32 临时屏蔽掉输错身份码的请求 2023-09-12 22:12:27 +08:00
John Smith
5dd56210d7 修复开发平台点赞消息报错 2023-09-12 21:54:15 +08:00
John Smith
b5c7554365 更新版本号v1.8.0 2023-09-10 10:40:19 +08:00
John Smith
c6272c908a 添加身份码相关的提示 2023-09-10 10:39:02 +08:00
John Smith
926ee879a6 修复打包后的报错 2023-09-10 09:48:12 +08:00
John Smith
a35d7a5bfc 移除自动缩小头像尺寸 2023-09-10 00:06:33 +08:00
John Smith
72c11d60da 移除从用户中心获取头像的接口 2023-09-10 00:03:29 +08:00
John Smith
a50461d4e6 添加重连次数太多的保险措施 2023-09-09 23:54:49 +08:00
John Smith
78828963ce 支持使用身份码时前端直连 2023-09-09 23:16:13 +08:00
John Smith
68f8ab3a92 前端支持使用身份码 2023-09-09 17:13:53 +08:00
John Smith
cad573312b 前端初始化房间时从后端接口获取文本表情 2023-09-08 23:54:47 +08:00
John Smith
3fb1c41845 前端直连回退到uid=0的版本,避免被限流 2023-09-08 23:49:35 +08:00
John Smith
6d07110927 添加获取文本表情的接口 2023-09-08 23:00:45 +08:00
John Smith
de0bac3119 添加开放平台接口的代理 2023-09-08 20:53:04 +08:00
John Smith
07569033fa 通过服务器转发时支持开放平台接口 2023-09-07 00:56:32 +08:00
John Smith
72841ab46e 优雅地停机 2023-09-06 21:34:04 +08:00
John Smith
fe141bc8fd 升级blivedm 2023-09-03 22:53:15 +08:00
37 changed files with 1929 additions and 774 deletions

View File

@@ -1,11 +1,15 @@
# blivechat
用于OBS的仿YouTube风格的bilibili直播评论栏
![OBS截图](./screenshots/obs.png)
![Chrome截图](./screenshots/chrome.png)
![样式生成器截图](./screenshots/stylegen.png)
![OBS截图](./screenshots/obs.png)
![Chrome截图](./screenshots/chrome.png)
![样式生成器截图](./screenshots/stylegen.png)
## 特性
* 兼容YouTube直播评论栏的样式
* 付费礼物模仿醒目留言显示
* 高亮舰队、房管、主播的用户名
@@ -17,70 +21,93 @@
* 支持配置自定义表情不需要开通B站官方表情
## 使用方法
以下几种方式任选一种即可
### 一、本地使用
1. 下载[发布版](https://github.com/xfgryujk/blivechat/releases)仅提供x64 Windows版
2. 双击`blivechat.exe`运行服务器或者用命令行可以指定host和端口号
```sh
blivechat.exe --host 127.0.0.1 --port 12450
```
3. 用浏览器打开[http://localhost:12450](http://localhost:12450)输入房间ID复制房间URL
或者也可以在配置文件里指定host和端口号
3. 用浏览器打开[http://localhost:12450](http://localhost:12450)输入主播在开始直播时获得的身份码复制房间URL
4. 用样式生成器生成样式复制CSS
5. 在OBS中添加浏览器源输入URL和自定义CSS
**注意事项:**
* 本地使用时不要关闭blivechat.exe那个黑框否则不能继续获取头像或弹幕
* 本地使用时不要关闭blivechat.exe那个黑框否则不能继续获取弹幕
* 样式生成器没有列出所有本地字体,但是可以手动输入本地字体
* 如果需要使用翻译功能,建议看[配置官方翻译接口傻瓜式教程](https://www.bilibili.com/read/cv14663633)
### 二、公共服务器
请优先在本地使用,使用公共服务器会有更大的延迟,而且服务器故障时可能发生直播事故
* [公共服务器](http://chat.bilisc.com/)
### 三、源代码版自建服务器或在Windows以外平台
0. 由于使用了git子模块clone时需要加上`--recursive`参数:
```sh
git clone --recursive https://github.com/xfgryujk/blivechat.git
```
如果已经clone拉子模块的方法
```sh
git submodule update --init --recursive
```
1. 编译前端需要安装Node.js
```sh
cd frontend
npm i
npm run build
```
2. 运行服务器需要Python3.8以上版本):
```sh
pip3 install -r requirements.txt
python3 main.py
```
或者可以指定host和端口号
```sh
python3 main.py --host 127.0.0.1 --port 12450
```
3. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
### 四、Docker自建服务器
1. ```sh
docker run --name blivechat -d -p 12450:12450 \
--mount source=blivechat-data,target=/mnt/data \
xfgryujk/blivechat:latest
```
2. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
## 自建服务器相关补充
### 服务器配置
服务器配置在`data/config.ini`,可以配置数据库和允许自动翻译等,编辑后要重启生效
**自建服务器时强烈建议不使用加载器**否则可能因为混合HTTP和HTTPS等原因加载不出来
### 参考nginx配置
`sudo vim /etc/nginx/sites-enabled/blivechat.conf`
```nginx

View File

@@ -1,13 +1,14 @@
# -*- coding: utf-8 -*-
import json
from typing import *
import tornado.web
class ApiHandler(tornado.web.RequestHandler): # noqa
class ApiHandler(tornado.web.RequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.json_args = None
self.json_args: Optional[dict] = None
def prepare(self):
self.set_header('Cache-Control', 'no-cache')

View File

@@ -12,7 +12,7 @@ import aiohttp
import tornado.websocket
import api.base
import blivedm.blivedm.client as blivedm_client
import blivedm.blivedm.clients.web as dm_web_cli
import config
import services.avatar
import services.chat
@@ -31,6 +31,7 @@ class Command(enum.IntEnum):
ADD_SUPER_CHAT = 5
DEL_SUPER_CHAT = 6
UPDATE_TRANSLATION = 7
FATAL_ERROR = 8
class ContentType(enum.IntEnum):
@@ -38,6 +39,10 @@ class ContentType(enum.IntEnum):
EMOTICON = 1
class FatalErrorType(enum.IntEnum):
AUTH_CODE_ERROR = 1
def make_message_body(cmd, data):
return json.dumps(
{
@@ -63,7 +68,6 @@ def make_text_message_data(
translation: str = '',
content_type: int = ContentType.TEXT,
content_type_params: list = None,
text_emoticons: Iterable[Tuple[str, str]] = None
):
# 为了节省带宽用list而不是dict
return [
@@ -98,7 +102,7 @@ def make_text_message_data(
# 14: contentTypeParams
content_type_params if content_type_params is not None else [],
# 15: textEmoticons
text_emoticons if text_emoticons is not None else [],
[], # 已废弃,保留
]
@@ -118,7 +122,7 @@ def make_translation_message_data(msg_id, translation):
]
class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
class ChatHandler(tornado.websocket.WebSocketHandler):
HEARTBEAT_INTERVAL = 10
RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5
@@ -127,7 +131,7 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
self._heartbeat_timer_handle = None
self._receive_timeout_timer_handle = None
self.room_id = None
self.room_key: Optional[services.chat.RoomKey] = None
self.auto_translate = False
def open(self):
@@ -156,9 +160,9 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
self.close()
def on_close(self):
logger.info('client=%s disconnected, room=%s', self.request.remote_ip, str(self.room_id))
logger.info('client=%s disconnected, room=%s', self.request.remote_ip, self.room_key)
if self.has_joined_room:
services.chat.client_room_manager.del_client(self.room_id, self)
services.chat.client_room_manager.del_client(self.room_key, self)
if self._heartbeat_timer_handle is not None:
self._heartbeat_timer_handle.cancel()
self._heartbeat_timer_handle = None
@@ -169,7 +173,7 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
def on_message(self, message):
try:
body = json.loads(message)
cmd = body['cmd']
cmd = int(body['cmd'])
if cmd == Command.HEARTBEAT:
# 超时没有加入房间也断开
@@ -177,20 +181,7 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
self._refresh_receive_timeout_timer()
elif cmd == Command.JOIN_ROOM:
if self.has_joined_room:
return
self._refresh_receive_timeout_timer()
self.room_id = int(body['data']['roomId'])
logger.info('client=%s joining room %d', self.request.remote_ip, self.room_id)
try:
cfg = body['data']['config']
self.auto_translate = bool(cfg['autoTranslate'])
except KeyError:
pass
services.chat.client_room_manager.add_client(self.room_id, self)
asyncio.create_task(self._on_joined_room())
self._on_join_room_req(body)
else:
logger.warning('client=%s unknown cmd=%d, body=%s', self.request.remote_ip, cmd, body)
@@ -198,6 +189,41 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
except Exception: # noqa
logger.exception('client=%s on_message error, message=%s', self.request.remote_ip, message)
def _on_join_room_req(self, body: dict):
if self.has_joined_room:
return
data = body['data']
room_key_dict = data.get('roomKey', None)
if room_key_dict is not None:
room_key_type = services.chat.RoomKeyType(room_key_dict['type'])
room_key_value = room_key_dict['value']
if room_key_type == services.chat.RoomKeyType.ROOM_ID:
if not isinstance(room_key_value, int):
raise TypeError(f'Room key value type error, value={room_key_value}')
elif room_key_type == services.chat.RoomKeyType.AUTH_CODE:
if not isinstance(room_key_value, str):
raise TypeError(f'Room key value type error, value={room_key_value}')
else:
raise ValueError(f'Unknown RoomKeyType={room_key_type}')
else:
# 兼容旧版客户端 TODO 过几个版本可以移除
room_key_type = services.chat.RoomKeyType.ROOM_ID
room_key_value = int(data['roomId'])
self.room_key = services.chat.RoomKey(room_key_type, room_key_value)
logger.info('client=%s joining room %s', self.request.remote_ip, self.room_key)
try:
cfg = data['config']
self.auto_translate = bool(cfg['autoTranslate'])
except KeyError:
pass
services.chat.client_room_manager.add_client(self.room_key, self)
asyncio.create_task(self._on_joined_room())
self._refresh_receive_timeout_timer()
# 跨域测试用
def check_origin(self, origin):
if self.application.settings['debug']:
@@ -206,7 +232,7 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
@property
def has_joined_room(self):
return self.room_id is not None
return self.room_key is not None
def send_cmd_data(self, cmd, data):
self.send_body_no_raise(make_message_body(cmd, data))
@@ -224,7 +250,12 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
# 不允许自动翻译的提示
if self.auto_translate:
cfg = config.get_config()
if cfg.allow_translate_rooms and self.room_id not in cfg.allow_translate_rooms:
if (
cfg.allow_translate_rooms
# 身份码就不管了吧,反正配置正确的情况下不会看到这个提示
and self.room_key.type == services.chat.RoomKeyType.ROOM_ID
and self.room_key.value not in cfg.allow_translate_rooms
):
self.send_cmd_data(Command.ADD_TEXT, make_text_message_data(
author_name='blivechat',
author_type=2,
@@ -235,7 +266,7 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
# 测试用
async def _send_test_message(self):
base_data = {
'avatarUrl': await services.avatar.get_avatar_url(300474),
'avatarUrl': await services.avatar.get_avatar_url(300474, 'xfgryujk'),
'timestamp': int(time.time()),
'authorName': 'xfgryujk',
}
@@ -268,13 +299,11 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
self.send_cmd_data(Command.ADD_TEXT, text_data)
text_data[4] = 'te[dog]st'
text_data[11] = uuid.uuid4().hex
text_data[15] = [('[dog]', 'http://i0.hdslb.com/bfs/live/4428c84e694fbf4e0ef6c06e958d9352c3582740.png')]
self.send_cmd_data(Command.ADD_TEXT, text_data)
text_data[2] = '主播'
text_data[3] = 3
text_data[4] = "I can eat glass, it doesn't hurt me."
text_data[11] = uuid.uuid4().hex
text_data[15] = []
self.send_cmd_data(Command.ADD_TEXT, text_data)
self.send_cmd_data(Command.ADD_MEMBER, member_data)
self.send_cmd_data(Command.ADD_SUPER_CHAT, sc_data)
@@ -290,12 +319,13 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
self.send_cmd_data(Command.ADD_GIFT, gift_data)
class RoomInfoHandler(api.base.ApiHandler): # noqa
class RoomInfoHandler(api.base.ApiHandler):
async def get(self):
room_id = int(self.get_query_argument('roomId'))
logger.info('client=%s getting room info, room=%d', self.request.remote_ip, room_id)
room_id, owner_uid = await self._get_room_info(room_id)
host_server_list = await self._get_server_host_list(room_id)
# 连接其他host必须要key
host_server_list = dm_web_cli.DEFAULT_DANMAKU_SERVER_LIST
if owner_uid == 0:
# 缓存3分钟
self.set_header('Cache-Control', 'private, max-age=180')
@@ -312,7 +342,7 @@ class RoomInfoHandler(api.base.ApiHandler): # noqa
async def _get_room_info(room_id):
try:
async with utils.request.http_session.get(
blivedm_client.ROOM_INIT_URL,
dm_web_cli.ROOM_INIT_URL,
headers={
**utils.request.BILIBILI_COMMON_HEADERS,
'Origin': 'https://live.bilibili.com',
@@ -338,23 +368,33 @@ class RoomInfoHandler(api.base.ApiHandler): # noqa
room_info = data['data']['room_info']
return room_info['room_id'], room_info['uid']
@staticmethod
async def _get_server_host_list(_room_id):
# 连接其他host必须要key
return blivedm_client.DEFAULT_DANMAKU_SERVER_LIST
class AvatarHandler(api.base.ApiHandler): # noqa
class AvatarHandler(api.base.ApiHandler):
async def get(self):
uid = int(self.get_query_argument('uid'))
username = self.get_query_argument('username', '')
avatar_url = await services.avatar.get_avatar_url_or_none(uid)
if avatar_url is None:
avatar_url = services.avatar.DEFAULT_AVATAR_URL
avatar_url = services.avatar.get_default_avatar_url(uid, username)
# 缓存3分钟
self.set_header('Cache-Control', 'private, max-age=180')
else:
# 缓存1天
self.set_header('Cache-Control', 'private, max-age=86400')
self.write({
'avatarUrl': avatar_url
})
self.write({'avatarUrl': avatar_url})
class TextEmoticonMappingsHandler(api.base.ApiHandler):
async def get(self):
# 缓存1天
self.set_header('Cache-Control', 'private, max-age=86400')
cfg = config.get_config()
self.write({'textEmoticons': cfg.text_emoticons})
ROUTES = [
(r'/api/chat', ChatHandler),
(r'/api/room_info', RoomInfoHandler),
(r'/api/avatar_url', AvatarHandler),
(r'/api/text_emoticon_mappings', TextEmoticonMappingsHandler),
]

View File

@@ -16,7 +16,7 @@ EMOTICON_UPLOAD_PATH = os.path.join(config.DATA_PATH, 'emoticons')
EMOTICON_BASE_URL = '/emoticons'
class MainHandler(tornado.web.StaticFileHandler): # noqa
class MainHandler(tornado.web.StaticFileHandler):
"""为了使用Vue Router的history模式把不存在的文件请求转发到index.html"""
async def get(self, path, include_body=True):
if path == '':
@@ -37,7 +37,7 @@ class MainHandler(tornado.web.StaticFileHandler): # noqa
await super().get('index.html', include_body)
class ServerInfoHandler(api.base.ApiHandler): # noqa
class ServerInfoHandler(api.base.ApiHandler):
async def get(self):
cfg = config.get_config()
self.write({
@@ -50,7 +50,7 @@ class ServerInfoHandler(api.base.ApiHandler): # noqa
})
class UploadEmoticonHandler(api.base.ApiHandler): # noqa
class UploadEmoticonHandler(api.base.ApiHandler):
async def post(self):
cfg = config.get_config()
if not cfg.enable_upload_file:
@@ -68,9 +68,7 @@ class UploadEmoticonHandler(api.base.ApiHandler): # noqa
url = await asyncio.get_running_loop().run_in_executor(
None, self._save_file, file.body, self.request.remote_ip
)
self.write({
'url': url
})
self.write({'url': url})
@staticmethod
def _save_file(body, client):
@@ -85,3 +83,14 @@ class UploadEmoticonHandler(api.base.ApiHandler): # noqa
os.replace(tmp_path, path)
return f'{EMOTICON_BASE_URL}/{filename}'
ROUTES = [
(r'/api/server_info', ServerInfoHandler),
(r'/api/emoticon', UploadEmoticonHandler),
]
# 通配的放在最后
LAST_ROUTES = [
(rf'{EMOTICON_BASE_URL}/(.*)', tornado.web.StaticFileHandler, {'path': EMOTICON_UPLOAD_PATH}),
(r'/(.*)', MainHandler, {'path': config.WEB_ROOT}),
]

245
api/open_live.py Normal file
View File

@@ -0,0 +1,245 @@
# -*- coding: utf-8 -*-
import asyncio
import datetime
import hashlib
import hmac
import json
import logging
import random
import re
from typing import *
import aiohttp
import cachetools
import tornado.web
import api.base
import config
import utils.request
logger = logging.getLogger(__name__)
START_GAME_OPEN_LIVE_URL = 'https://live-open.biliapi.com/v2/app/start'
END_GAME_OPEN_LIVE_URL = 'https://live-open.biliapi.com/v2/app/end'
GAME_HEARTBEAT_OPEN_LIVE_URL = 'https://live-open.biliapi.com/v2/app/heartbeat'
COMMON_SERVER_BASE_URL = 'https://chat.bilisc.com'
START_GAME_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/start_game'
END_GAME_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/end_game'
GAME_HEARTBEAT_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/game_heartbeat'
_error_auth_code_cache = cachetools.LRUCache(256)
class TransportError(Exception):
"""网络错误或HTTP状态码错误"""
class BusinessError(Exception):
"""业务返回码错误"""
def __init__(self, data: dict):
super().__init__(f"code={data['code']}, message={data['message']}, request_id={data['request_id']}")
self.data = data
@property
def code(self) -> int:
return self.data['code']
async def request_open_live_or_common_server(open_live_url, common_server_url, body: dict) -> dict:
"""如果配置了开放平台,则直接请求,否则转发请求到公共服务器的内部接口"""
cfg = config.get_config()
if cfg.is_open_live_configured:
return await _request_open_live(open_live_url, body)
try:
req_ctx_mgr = utils.request.http_session.post(common_server_url, json=body)
return await _read_response(req_ctx_mgr)
except TransportError:
logger.exception('Request common server failed:')
raise
except BusinessError as e:
logger.warning('Request common server failed: %s', e)
raise
async def _request_open_live(url, body: dict) -> dict:
cfg = config.get_config()
assert cfg.is_open_live_configured
# 输错身份码的人太多了屏蔽掉明显错误的请求防止B站抱怨
if url == START_GAME_OPEN_LIVE_URL:
auth_code = body.get('code', '')
_validate_auth_code(auth_code)
else:
auth_code = ''
body_bytes = json.dumps(body).encode('utf-8')
headers = {
'x-bili-accesskeyid': cfg.open_live_access_key_id,
'x-bili-content-md5': hashlib.md5(body_bytes).hexdigest(),
'x-bili-signature-method': 'HMAC-SHA256',
'x-bili-signature-nonce': str(random.randint(0, 999999999)),
'x-bili-signature-version': '1.0',
'x-bili-timestamp': str(int(datetime.datetime.now().timestamp())),
}
str_to_sign = '\n'.join(
f'{key}:{value}'
for key, value in headers.items()
)
signature = hmac.new(
cfg.open_live_access_key_secret.encode('utf-8'), str_to_sign.encode('utf-8'), hashlib.sha256
).hexdigest()
headers['Authorization'] = signature
headers['Content-Type'] = 'application/json'
headers['Accept'] = 'application/json'
try:
req_ctx_mgr = utils.request.http_session.post(url, headers=headers, data=body_bytes)
return await _read_response(req_ctx_mgr)
except TransportError:
logger.exception('Request open live failed:')
raise
except BusinessError as e:
logger.warning('Request open live failed: %s', e)
if e.code == 7007:
_error_auth_code_cache[auth_code] = True
raise
async def _read_response(req_ctx_mgr: AsyncContextManager[aiohttp.ClientResponse]) -> dict:
try:
async with req_ctx_mgr as r:
r.raise_for_status()
data = await r.json()
code = data['code']
if code != 0:
raise BusinessError(data)
return data
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
raise TransportError(f'{type(e).__name__}: {e}')
def _validate_auth_code(auth_code):
if (
auth_code in _error_auth_code_cache
# 我也不知道是不是一定是这个格式,先这么处理
or not re.fullmatch(r'[0-9A-Z]{12,14}', auth_code)
):
raise BusinessError({
'code': 7007,
'message': 'oioioi你的身份码错误了别再重试了',
'request_id': '0',
'data': None
})
class _OpenLiveHandlerBase(api.base.ApiHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.res: Optional[dict] = None
def prepare(self):
super().prepare()
if not isinstance(self.json_args, dict):
raise tornado.web.MissingArgumentError('body')
if 'app_id' in self.json_args:
cfg = config.get_config()
self.json_args['app_id'] = cfg.open_live_app_id
logger.info('client=%s requesting open live, cls=%s', self.request.remote_ip, type(self).__name__)
class _PublicHandlerBase(_OpenLiveHandlerBase):
"""外部接口,如果配置了开放平台,则直接请求,否则转发请求到公共服务器的内部接口"""
_OPEN_LIVE_URL: str
_COMMON_SERVER_URL: str
async def post(self):
try:
self.res = await request_open_live_or_common_server(
self._OPEN_LIVE_URL, self._COMMON_SERVER_URL, self.json_args
)
except TransportError:
raise tornado.web.HTTPError(500)
except BusinessError as e:
self.res = e.data
self.write(self.res)
class _PrivateHandlerBase(_OpenLiveHandlerBase):
"""内部接口,如果配置了开放平台,则直接请求,否则响应错误"""
_OPEN_LIVE_URL: str
async def post(self):
cfg = config.get_config()
if not cfg.is_open_live_configured:
raise tornado.web.HTTPError(501)
try:
self.res = await _request_open_live(self._OPEN_LIVE_URL, self.json_args)
except TransportError:
raise tornado.web.HTTPError(500)
except BusinessError as e:
self.res = e.data
self.write(self.res)
class _StartGameMixin(_OpenLiveHandlerBase):
_OPEN_LIVE_URL = START_GAME_OPEN_LIVE_URL
_COMMON_SERVER_URL = START_GAME_COMMON_SERVER_URL
async def post(self):
await super().post() # noqa
if self.res is None:
return
try:
room_id = self.res['data']['anchor_info']['room_id']
except (TypeError, KeyError):
room_id = None
code = self.res['code']
logger.info('room_id=%s start game res: %s %s', room_id, code, self.res['message'])
if code == 7007:
# 身份码错误
# 让我看看是哪个混蛋把房间ID、UID当做身份码
logger.info('Auth code error! auth_code=%s', self.json_args.get('code', None))
class StartGamePublicHandler(_StartGameMixin, _PublicHandlerBase):
pass
class StartGamePrivateHandler(_StartGameMixin, _PrivateHandlerBase):
pass
class EndGamePublicHandler(_PublicHandlerBase):
_OPEN_LIVE_URL = END_GAME_OPEN_LIVE_URL
_COMMON_SERVER_URL = END_GAME_COMMON_SERVER_URL
class EndGamePrivateHandler(_PrivateHandlerBase):
_OPEN_LIVE_URL = END_GAME_OPEN_LIVE_URL
class GameHeartbeatPublicHandler(_PublicHandlerBase):
_OPEN_LIVE_URL = GAME_HEARTBEAT_OPEN_LIVE_URL
_COMMON_SERVER_URL = GAME_HEARTBEAT_COMMON_SERVER_URL
class GameHeartbeatPrivateHandler(_PrivateHandlerBase):
_OPEN_LIVE_URL = GAME_HEARTBEAT_OPEN_LIVE_URL
ROUTES = [
(r'/api/open_live/start_game', StartGamePublicHandler),
(r'/api/internal/open_live/start_game', StartGamePrivateHandler),
(r'/api/open_live/end_game', EndGamePublicHandler),
(r'/api/internal/open_live/end_game', EndGamePrivateHandler),
(r'/api/open_live/game_heartbeat', GameHeartbeatPublicHandler),
(r'/api/internal/open_live/game_heartbeat', GameHeartbeatPrivateHandler),
]

Submodule blivedm updated: 28645d5e37...4da27950c8

View File

@@ -57,15 +57,27 @@ class AppConfig:
self.open_browser_at_startup = True
self.enable_upload_file = True
self.fetch_avatar_max_queue_size = 1
self.fetch_avatar_max_queue_size = 4
self.avatar_cache_size = 10000
self.open_live_access_key_id = ''
self.open_live_access_key_secret = ''
self.open_live_app_id = 0
self.enable_translate = True
self.allow_translate_rooms = set()
self.translate_max_queue_size = 10
self.translation_cache_size = 50000
self.translator_configs = []
self.text_emoticons = []
@property
def is_open_live_configured(self):
return (
self.open_live_access_key_id != '' and self.open_live_access_key_secret != '' and self.open_live_app_id != 0
)
def load(self, path):
try:
config = configparser.ConfigParser()
@@ -73,6 +85,7 @@ class AppConfig:
self._load_app_config(config)
self._load_translator_configs(config)
self._load_text_emoticons(config)
except Exception: # noqa
logger.exception('Failed to load config:')
return False
@@ -81,19 +94,25 @@ class AppConfig:
def _load_app_config(self, config: configparser.ConfigParser):
app_section = config['app']
self.host = app_section.get('host', self.host)
self.port = app_section.getint('port', fallback=self.port)
self.port = app_section.getint('port', self.port)
self.database_url = app_section.get('database_url', self.database_url)
self.tornado_xheaders = app_section.getboolean('tornado_xheaders', fallback=self.tornado_xheaders)
self.tornado_xheaders = app_section.getboolean('tornado_xheaders', self.tornado_xheaders)
self.loader_url = app_section.get('loader_url', self.loader_url)
self.open_browser_at_startup = app_section.getboolean('open_browser_at_startup',
fallback=self.open_browser_at_startup)
self.enable_upload_file = app_section.getboolean('enable_upload_file', fallback=self.enable_upload_file)
self.open_browser_at_startup = app_section.getboolean('open_browser_at_startup', self.open_browser_at_startup)
self.enable_upload_file = app_section.getboolean('enable_upload_file', self.enable_upload_file)
self.fetch_avatar_max_queue_size = app_section.getint('fetch_avatar_max_queue_size',
fallback=self.fetch_avatar_max_queue_size)
self.avatar_cache_size = app_section.getint('avatar_cache_size', fallback=self.avatar_cache_size)
self.fetch_avatar_max_queue_size = app_section.getint(
'fetch_avatar_max_queue_size', self.fetch_avatar_max_queue_size
)
self.avatar_cache_size = app_section.getint('avatar_cache_size', self.avatar_cache_size)
self.enable_translate = app_section.getboolean('enable_translate', fallback=self.enable_translate)
self.open_live_access_key_id = app_section.get('open_live_access_key_id', self.open_live_access_key_id)
self.open_live_access_key_secret = app_section.get(
'open_live_access_key_secret', self.open_live_access_key_secret
)
self.open_live_app_id = app_section.getint('open_live_app_id', self.open_live_app_id)
self.enable_translate = app_section.getboolean('enable_translate', self.enable_translate)
self.allow_translate_rooms = _str_to_list(app_section.get('allow_translate_rooms', ''), int, set)
self.translate_max_queue_size = app_section.getint('translate_max_queue_size', self.translate_max_queue_size)
self.translation_cache_size = app_section.getint('translation_cache_size', self.translation_cache_size)
@@ -136,6 +155,14 @@ class AppConfig:
translator_configs.append(translator_config)
self.translator_configs = translator_configs
def _load_text_emoticons(self, config: configparser.ConfigParser):
mappings_section = config['text_emoticon_mappings']
text_emoticons = []
for value in mappings_section.values():
keyword, _, url = value.partition(',')
text_emoticons.append({'keyword': keyword, 'url': url})
self.text_emoticons = text_emoticons
def _str_to_list(value, item_type: Type = str, container_type: Type = list):
value = value.strip()

View File

@@ -61,6 +61,13 @@ translation_cache_size = 50000
# **The following is for translation team. Leave it default if you don't know its meaning**
# -------------------------------------------------------------------------------------------------
# 在B站直播开放平台申请的开发者密钥如果不填会把请求转发到作者的服务器
open_live_access_key_id =
open_live_access_key_secret =
# 在B站直播开放平台创建的项目ID如果不填会把请求转发到作者的服务器
open_live_app_id = 0
# 翻译器配置,索引到下面的配置节。可以以逗号分隔配置多个翻译器,翻译时会自动负载均衡
# 配置多个翻译器可以增加额度、增加QPS、容灾
# 不同配置可以使用同一个类型,但要使用不同的账号,否则还是会遇到额度、调用频率限制
@@ -137,3 +144,89 @@ target_language = jp
# 百度翻译开放平台应用ID和密钥
app_id =
secret =
# 傻逼B站获取表情都要登录开放平台也不发文本表情的URL我服了
[text_emoticon_mappings]
1 = [dog],http://i0.hdslb.com/bfs/live/4428c84e694fbf4e0ef6c06e958d9352c3582740.png
2 = [花],http://i0.hdslb.com/bfs/live/7dd2ef03e13998575e4d8a803c6e12909f94e72b.png
3 = [妙],http://i0.hdslb.com/bfs/live/08f735d950a0fba267dda140673c9ab2edf6410d.png
4 = [哇],http://i0.hdslb.com/bfs/live/650c3e22c06edcbca9756365754d38952fc019c3.png
5 = [爱],http://i0.hdslb.com/bfs/live/1daaa5d284dafaa16c51409447da851ff1ec557f.png
6 = [手机],http://i0.hdslb.com/bfs/live/b159f90431148a973824f596288e7ad6a8db014b.png
7 = [撇嘴],http://i0.hdslb.com/bfs/live/4255ce6ed5d15b60311728a803d03dd9a24366b2.png
8 = [委屈],http://i0.hdslb.com/bfs/live/69312e99a00d1db2de34ef2db9220c5686643a3f.png
9 = [抓狂],http://i0.hdslb.com/bfs/live/a7feb260bb5b15f97d7119b444fc698e82516b9f.png
10 = [比心],http://i0.hdslb.com/bfs/live/4e029593562283f00d39b99e0557878c4199c71d.png
11 = [赞],http://i0.hdslb.com/bfs/live/2dd666d3651bafe8683acf770b7f4163a5f49809.png
12 = [滑稽],http://i0.hdslb.com/bfs/live/8624fd172037573c8600b2597e3731ef0e5ea983.png
13 = [吃瓜],http://i0.hdslb.com/bfs/live/ffb53c252b085d042173379ac724694ce3196194.png
14 = [笑哭],http://i0.hdslb.com/bfs/live/c5436c6806c32b28d471bb23d42f0f8f164a187a.png
15 = [捂脸],http://i0.hdslb.com/bfs/live/e6073c6849f735ae6cb7af3a20ff7dcec962b4c5.png
16 = [喝彩],http://i0.hdslb.com/bfs/live/b51824125d09923a4ca064f0c0b49fc97d3fab79.png
17 = [偷笑],http://i0.hdslb.com/bfs/live/e2ba16f947a23179cdc00420b71cc1d627d8ae25.png
18 = [大笑],http://i0.hdslb.com/bfs/live/e2589d086df0db8a7b5ca2b1273c02d31d4433d4.png
19 = [惊喜],http://i0.hdslb.com/bfs/live/9c75761c5b6e1ff59b29577deb8e6ad996b86bd7.png
20 = [傲娇],http://i0.hdslb.com/bfs/live/b5b44f099059a1bafb2c2722cfe9a6f62c1dc531.png
21 = [疼],http://i0.hdslb.com/bfs/live/492b10d03545b7863919033db7d1ae3ef342df2f.png
22 = [吓],http://i0.hdslb.com/bfs/live/c6bed64ffb78c97c93a83fbd22f6fdf951400f31.png
23 = [阴险],http://i0.hdslb.com/bfs/live/a4df45c035b0ca0c58f162b5fb5058cf273d0d09.png
24 = [惊讶],http://i0.hdslb.com/bfs/live/bc26f29f62340091737c82109b8b91f32e6675ad.png
25 = [生病],http://i0.hdslb.com/bfs/live/84c92239591e5ece0f986c75a39050a5c61c803c.png
26 = [嘘],http://i0.hdslb.com/bfs/live/b6226219384befa5da1d437cb2ff4ba06c303844.png
27 = [奸笑],http://i0.hdslb.com/bfs/live/5935e6a4103d024955f749d428311f39e120a58a.png
28 = [囧],http://i0.hdslb.com/bfs/live/204413d3cf330e122230dcc99d29056f2a60e6f2.png
29 = [捂脸2],http://i0.hdslb.com/bfs/live/a2ad0cc7e390a303f6d243821479452d31902a5f.png
30 = [出窍],http://i0.hdslb.com/bfs/live/bb8e95fa54512ffea07023ea4f2abee4a163e7a0.png
31 = [吐了啊],http://i0.hdslb.com/bfs/live/2b6b4cc33be42c3257dc1f6ef3a39d666b6b4b1a.png
32 = [鼻子],http://i0.hdslb.com/bfs/live/f4ed20a70d0cb85a22c0c59c628aedfe30566b37.png
33 = [调皮],http://i0.hdslb.com/bfs/live/84fe12ecde5d3875e1090d83ac9027cb7d7fba9f.png
34 = [酸],http://i0.hdslb.com/bfs/live/98fd92c6115b0d305f544b209c78ec322e4bb4ff.png
35 = [冷],http://i0.hdslb.com/bfs/live/b804118a1bdb8f3bec67d9b108d5ade6e3aa93a9.png
36 = [OK],http://i0.hdslb.com/bfs/live/86268b09e35fbe4215815a28ef3cf25ec71c124f.png
37 = [微笑],http://i0.hdslb.com/bfs/live/f605dd8229fa0115e57d2f16cb019da28545452b.png
38 = [藏狐],http://i0.hdslb.com/bfs/live/05ef7849e7313e9c32887df922613a7c1ad27f12.png
39 = [龇牙],http://i0.hdslb.com/bfs/live/8b99266ea7b9e86cf9d25c3d1151d80c5ba5c9a1.png
40 = [防护],http://i0.hdslb.com/bfs/live/17435e60dcc28ce306762103a2a646046ff10b0a.png
41 = [笑],http://i0.hdslb.com/bfs/live/a91a27f83c38b5576f4cd08d4e11a2880de78918.png
42 = [一般],http://i0.hdslb.com/bfs/live/8d436de0c3701d87e4ca9c1be01c01b199ac198e.png
43 = [嫌弃],http://i0.hdslb.com/bfs/live/c409425ba1ad2c6534f0df7de350ba83a9c949e5.png
44 = [无语],http://i0.hdslb.com/bfs/live/4781a77be9c8f0d4658274eb4e3012c47a159f23.png
45 = [哈欠],http://i0.hdslb.com/bfs/live/6e496946725cd66e7ff1b53021bf1cc0fc240288.png
46 = [可怜],http://i0.hdslb.com/bfs/live/8e88e6a137463703e96d4f27629f878efa323456.png
47 = [歪嘴笑],http://i0.hdslb.com/bfs/live/bea1f0497888f3e9056d3ce14ba452885a485c02.png
48 = [亲亲],http://i0.hdslb.com/bfs/live/10662d9c0d6ddb3203ecf50e77788b959d4d1928.png
49 = [问号],http://i0.hdslb.com/bfs/live/a0c456b6d9e3187399327828a9783901323bfdb5.png
50 = [波吉],http://i0.hdslb.com/bfs/live/57dee478868ed9f1ce3cf25a36bc50bde489c404.png
51 = [OH],http://i0.hdslb.com/bfs/live/0d5123cddf389302df6f605087189fd10919dc3c.png
52 = [再见],http://i0.hdslb.com/bfs/live/f408e2af700adcc2baeca15510ef620bed8d4c43.png
53 = [白眼],http://i0.hdslb.com/bfs/live/7fa907ae85fa6327a0466e123aee1ac32d7c85f7.png
54 = [鼓掌],http://i0.hdslb.com/bfs/live/d581d0bc30c8f9712b46ec02303579840c72c42d.png
55 = [大哭],http://i0.hdslb.com/bfs/live/816402551e6ce30d08b37a917f76dea8851fe529.png
56 = [呆],http://i0.hdslb.com/bfs/live/179c7e2d232cd74f30b672e12fc728f8f62be9ec.png
57 = [流汗],http://i0.hdslb.com/bfs/live/b00e2e02904096377061ec5f93bf0dd3321f1964.png
58 = [生气],http://i0.hdslb.com/bfs/live/2c69dad2e5c0f72f01b92746bc9d148aee1993b2.png
59 = [加油],http://i0.hdslb.com/bfs/live/fbc3c8bc4152a65bbf4a9fd5a5d27710fbff2119.png
60 = [害羞],http://i0.hdslb.com/bfs/live/d8ce9b05c0e40cec61a15ba1979c8517edd270bf.png
61 = [虎年],http://i0.hdslb.com/bfs/live/a51af0d7d9e60ce24f139c468a3853f9ba9bb184.png
62 = [doge2],http://i0.hdslb.com/bfs/live/f547cc853cf43e70f1e39095d9b3b5ac1bf70a8d.png
63 = [金钱豹],http://i0.hdslb.com/bfs/live/b6e8131897a9a718ee280f2510bfa92f1d84429b.png
64 = [瓜子],http://i0.hdslb.com/bfs/live/fd35718ac5a278fd05fe5287ebd41de40a59259d.png
65 = [墨镜],http://i0.hdslb.com/bfs/live/5e01c237642c8b662a69e21b8e0fbe6e7dbc2aa1.png
66 = [难过],http://i0.hdslb.com/bfs/live/5776481e380648c0fb3d4ad6173475f69f1ce149.png
67 = [抱抱],http://i0.hdslb.com/bfs/live/abddb0b621b389fc8c2322b1cfcf122d8936ba91.png
68 = [跪了],http://i0.hdslb.com/bfs/live/4f2155b108047d60c1fa9dccdc4d7abba18379a0.png
69 = [摊手],http://i0.hdslb.com/bfs/live/1e0a2baf088a34d56e2cc226b2de36a5f8d6c926.png
70 = [热],http://i0.hdslb.com/bfs/live/6df760280b17a6cbac8c1874d357298f982ba4cf.png
71 = [三星堆],http://i0.hdslb.com/bfs/live/0a1ab3f0f2f2e29de35c702ac1ecfec7f90e325d.png
72 = [鼠],http://i0.hdslb.com/bfs/live/98f842994035505c728e32e32045d649e371ecd6.png
73 = [汤圆],http://i0.hdslb.com/bfs/live/23ae12d3a71b9d7a22c8773343969fcbb94b20d0.png
74 = [泼水],http://i0.hdslb.com/bfs/live/29533893115c4609a4af336f49060ea13173ca78.png
75 = [鬼魂],http://i0.hdslb.com/bfs/live/5d86d55ba9a2f99856b523d8311cf75cfdcccdbc.png
76 = [不行],http://i0.hdslb.com/bfs/live/607f74ccf5eec7d2b17d91b9bb36be61a5dd196b.png
77 = [响指],http://i0.hdslb.com/bfs/live/3b2fedf09b0ac79679b5a47f5eb3e8a38e702387.png
78 = [牛],http://i0.hdslb.com/bfs/live/5e61223561203c50340b4c9b41ba7e4b05e48ae2.png
79 = [保佑],http://i0.hdslb.com/bfs/live/241b13adb4933e38b7ea6f5204e0648725e76fbf.png
80 = [抱拳],http://i0.hdslb.com/bfs/live/3f170894dd08827ee293afcb5a3d2b60aecdb5b1.png
81 = [给力],http://i0.hdslb.com/bfs/live/d1ba5f4c54332a21ed2ca0dcecaedd2add587839.png
82 = [耶],http://i0.hdslb.com/bfs/live/eb2d84ba623e2335a48f73fb5bef87bcf53c1239.png

View File

@@ -1,6 +1,6 @@
{
"name": "blivechat",
"version": "1.7.0",
"version": "1.8.2",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
@@ -13,6 +13,7 @@
"downloadjs": "^1.4.7",
"element-ui": "^2.15.13",
"lodash": "^4.17.21",
"pako": "^2.1.0",
"vue": "^2.7.14",
"vue-i18n": "^8.28.2",
"vue-router": "^3.6.5"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 264 KiB

View File

@@ -0,0 +1,263 @@
import axios from 'axios'
import * as chat from '.'
import * as base from './ChatClientOfficialBase'
import ChatClientOfficialBase from './ChatClientOfficialBase'
const GAME_HEARTBEAT_INTERVAL = 20 * 1000
export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
constructor(roomOwnerAuthCode) {
super()
this.CMD_CALLBACK_MAP = CMD_CALLBACK_MAP
this.roomOwnerAuthCode = roomOwnerAuthCode
// 调用initRoom后初始化
this.roomOwnerUid = null
this.hostServerUrlList = []
this.authBody = null
this.gameId = null
this.gameHeartbeatTimerId = null
}
stop() {
super.stop()
if (this.gameHeartbeatTimerId) {
window.clearInterval(this.gameHeartbeatTimerId)
this.gameHeartbeatTimerId = null
}
this.endGame()
}
async initRoom() {
if (!await this.startGame()) {
return false
}
if (this.gameId && this.gameHeartbeatTimerId === null) {
this.gameHeartbeatTimerId = window.setInterval(this.sendGameHeartbeat.bind(this), GAME_HEARTBEAT_INTERVAL)
}
return true
}
async startGame() {
let res
try {
res = (await axios.post('/api/open_live/start_game', {
code: this.roomOwnerAuthCode,
app_id: 0
})).data
if (res.code !== 0) {
let msg = `code=${res.code}, message=${res.message}, request_id=${res.request_id}`
if (res.code === 7007) {
// 身份码错误
throw new chat.ChatClientFatalError(chat.FATAL_ERROR_TYPE_AUTH_CODE_ERROR, msg)
}
throw Error(msg)
}
} catch (e) {
console.error('startGame failed:', e)
if (e instanceof chat.ChatClientFatalError) {
throw e
}
return false
}
let data = res.data
this.gameId = data.game_info.game_id
let websocketInfo = data.websocket_info
this.authBody = websocketInfo.auth_body
this.hostServerUrlList = websocketInfo.wss_link
let anchorInfo = data.anchor_info
// this.roomId = anchorInfo.room_id
this.roomOwnerUid = anchorInfo.uid
return true
}
async endGame() {
if (!this.gameId) {
return true
}
try {
let res = (await axios.post('/api/open_live/end_game', {
app_id: 0,
game_id: this.gameId
})).data
if (res.code !== 0) {
if (res.code === 7000 || res.code === 7003) {
// 项目已经关闭了也算成功
return true
}
throw Error(`code=${res.code}, message=${res.message}, request_id=${res.request_id}`)
}
} catch (e) {
console.error('endGame failed:', e)
return false
}
return true
}
async sendGameHeartbeat() {
if (!this.gameId) {
return false
}
// 保存一下防止await之后gameId改变
let gameId = this.gameId
try {
let res = (await axios.post('/api/open_live/game_heartbeat', {
game_id: this.gameId
})).data
if (res.code !== 0) {
console.error(`sendGameHeartbeat failed: code=${res.code}, message=${res.message}, request_id=${res.request_id}`)
if (res.code === 7003 && this.gameId === gameId) {
// 项目异常关闭,可能是心跳超时,需要重新开启项目
this.needInitRoom = true
this.discardWebsocket()
}
return false
}
} catch (e) {
console.error('sendGameHeartbeat failed:', e)
return false
}
return true
}
async onBeforeWsConnect() {
// 重连次数太多则重新init_room保险
let reinitPeriod = Math.max(3, (this.hostServerUrlList || []).length)
if (this.retryCount > 0 && this.retryCount % reinitPeriod === 0) {
this.needInitRoom = true
}
return super.onBeforeWsConnect()
}
getWsUrl() {
return this.hostServerUrlList[this.retryCount % this.hostServerUrlList.length]
}
sendAuth() {
this.websocket.send(this.makePacket(this.authBody, base.OP_AUTH))
}
async dmCallback(command) {
if (!this.onAddText) {
return
}
let data = command.data
let authorType
if (data.uid === this.roomOwnerUid) {
authorType = 3
} else if (data.guard_level !== 0) {
authorType = 1
} else {
authorType = 0
}
let emoticon = null
if (data.dm_type === 1) {
emoticon = data.emoji_img_url
}
data = {
avatarUrl: chat.processAvatarUrl(data.uface),
timestamp: data.timestamp,
authorName: data.uname,
authorType: authorType,
content: data.msg,
privilegeType: data.guard_level,
isGiftDanmaku: chat.isGiftDanmakuByContent(data.msg),
authorLevel: 1,
isNewbie: false,
isMobileVerified: true,
medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0,
id: data.msg_id,
translation: '',
emoticon: emoticon,
}
this.onAddText(data)
}
sendGiftCallback(command) {
if (!this.onAddGift) {
return
}
let data = command.data
if (!data.paid) { // 丢人
return
}
data = {
id: data.msg_id,
avatarUrl: chat.processAvatarUrl(data.uface),
timestamp: data.timestamp,
authorName: data.uname,
totalCoin: data.price * data.gift_num,
giftName: data.gift_name,
num: data.gift_num
}
this.onAddGift(data)
}
async guardCallback(command) {
if (!this.onAddMember) {
return
}
let data = command.data
data = {
id: data.msg_id,
avatarUrl: chat.processAvatarUrl(data.user_info.uface),
timestamp: data.timestamp,
authorName: data.user_info.uname,
privilegeType: data.guard_level
}
this.onAddMember(data)
}
superChatCallback(command) {
if (!this.onAddSuperChat) {
return
}
let data = command.data
data = {
id: data.message_id.toString(),
avatarUrl: chat.processAvatarUrl(data.uface),
timestamp: data.start_time,
authorName: data.uname,
price: data.rmb,
content: data.message,
translation: ''
}
this.onAddSuperChat(data)
}
superChatDelCallback(command) {
if (!this.onDelSuperChat) {
return
}
let ids = []
for (let id of command.data.message_ids) {
ids.push(id.toString())
}
this.onDelSuperChat({ ids })
}
}
const CMD_CALLBACK_MAP = {
LIVE_OPEN_PLATFORM_DM: ChatClientDirectOpenLive.prototype.dmCallback,
LIVE_OPEN_PLATFORM_SEND_GIFT: ChatClientDirectOpenLive.prototype.sendGiftCallback,
LIVE_OPEN_PLATFORM_GUARD: ChatClientDirectOpenLive.prototype.guardCallback,
LIVE_OPEN_PLATFORM_SUPER_CHAT: ChatClientDirectOpenLive.prototype.superChatCallback,
LIVE_OPEN_PLATFORM_SUPER_CHAT_DEL: ChatClientDirectOpenLive.prototype.superChatDelCallback
}

View File

@@ -0,0 +1,187 @@
import axios from 'axios'
import { getUuid4Hex } from '@/utils'
import * as chat from '.'
import * as base from './ChatClientOfficialBase'
import ChatClientOfficialBase from './ChatClientOfficialBase'
export default class ChatClientDirectWeb extends ChatClientOfficialBase {
constructor(roomId) {
super()
this.CMD_CALLBACK_MAP = CMD_CALLBACK_MAP
// 调用initRoom后初始化如果失败使用这里的默认值
this.roomId = roomId
this.roomOwnerUid = -1
this.hostServerList = [
{ host: "broadcastlv.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244 }
]
}
async initRoom() {
let res
try {
res = (await axios.get('/api/room_info', { params: {
roomId: this.roomId
} })).data
} catch {
return true
}
this.roomId = res.roomId
this.roomOwnerUid = res.ownerUid
if (res.hostServerList.length !== 0) {
this.hostServerList = res.hostServerList
}
return true
}
async onBeforeWsConnect() {
// 重连次数太多则重新init_room保险
let reinitPeriod = Math.max(3, (this.hostServerList || []).length)
if (this.retryCount > 0 && this.retryCount % reinitPeriod === 0) {
this.needInitRoom = true
}
return super.onBeforeWsConnect()
}
getWsUrl() {
let hostServer = this.hostServerList[this.retryCount % this.hostServerList.length]
return `wss://${hostServer.host}:${hostServer.wss_port}/sub`
}
sendAuth() {
let authParams = {
uid: 0,
roomid: this.roomId,
protover: 3,
platform: 'web',
type: 2,
buvid: '',
}
this.websocket.send(this.makePacket(authParams, base.OP_AUTH))
}
async danmuMsgCallback(command) {
if (!this.onAddText) {
return
}
let info = command.info
let roomId, medalLevel
if (info[3]) {
roomId = info[3][3]
medalLevel = info[3][0]
} else {
roomId = medalLevel = 0
}
let uid = info[2][0]
let isAdmin = info[2][2]
let privilegeType = info[7]
let authorType
if (uid === this.roomOwnerUid) {
authorType = 3
} else if (isAdmin) {
authorType = 2
} else if (privilegeType !== 0) {
authorType = 1
} else {
authorType = 0
}
let authorName = info[2][1]
let content = info[1]
let data = {
avatarUrl: await chat.getAvatarUrl(uid, authorName),
timestamp: info[0][4] / 1000,
authorName: authorName,
authorType: authorType,
content: content,
privilegeType: privilegeType,
isGiftDanmaku: Boolean(info[0][9]) || chat.isGiftDanmakuByContent(content),
authorLevel: info[4][0],
isNewbie: info[2][5] < 10000,
isMobileVerified: Boolean(info[2][6]),
medalLevel: roomId === this.roomId ? medalLevel : 0,
id: getUuid4Hex(),
translation: '',
emoticon: info[0][13].url || null,
}
this.onAddText(data)
}
sendGiftCallback(command) {
if (!this.onAddGift) {
return
}
let data = command.data
if (data.coin_type !== 'gold') { // 丢人
return
}
data = {
id: getUuid4Hex(),
avatarUrl: chat.processAvatarUrl(data.face),
timestamp: data.timestamp,
authorName: data.uname,
totalCoin: data.total_coin,
giftName: data.giftName,
num: data.num
}
this.onAddGift(data)
}
async guardBuyCallback(command) {
if (!this.onAddMember) {
return
}
let data = command.data
data = {
id: getUuid4Hex(),
avatarUrl: await chat.getAvatarUrl(data.uid, data.username),
timestamp: data.start_time,
authorName: data.username,
privilegeType: data.guard_level
}
this.onAddMember(data)
}
superChatMessageCallback(command) {
if (!this.onAddSuperChat) {
return
}
let data = command.data
data = {
id: data.id.toString(),
avatarUrl: chat.processAvatarUrl(data.user_info.face),
timestamp: data.start_time,
authorName: data.user_info.uname,
price: data.price,
content: data.message,
translation: ''
}
this.onAddSuperChat(data)
}
superChatMessageDeleteCallback(command) {
if (!this.onDelSuperChat) {
return
}
let ids = []
for (let id of command.data.ids) {
ids.push(id.toString())
}
this.onDelSuperChat({ ids })
}
}
const CMD_CALLBACK_MAP = {
DANMU_MSG: ChatClientDirectWeb.prototype.danmuMsgCallback,
SEND_GIFT: ChatClientDirectWeb.prototype.sendGiftCallback,
GUARD_BUY: ChatClientDirectWeb.prototype.guardBuyCallback,
SUPER_CHAT_MESSAGE: ChatClientDirectWeb.prototype.superChatMessageCallback,
SUPER_CHAT_MESSAGE_DELETE: ChatClientDirectWeb.prototype.superChatMessageDeleteCallback
}

View File

@@ -1,40 +1,39 @@
import axios from 'axios'
import { BrotliDecode } from './brotli_decode'
import { getUuid4Hex } from '@/utils'
import * as avatar from '../avatar'
import { inflate } from 'pako'
import * as chat from '..'
const HEADER_SIZE = 16
// const WS_BODY_PROTOCOL_VERSION_NORMAL = 0
// const WS_BODY_PROTOCOL_VERSION_HEARTBEAT = 1
// const WS_BODY_PROTOCOL_VERSION_DEFLATE = 2
const WS_BODY_PROTOCOL_VERSION_BROTLI = 3
export const WS_BODY_PROTOCOL_VERSION_NORMAL = 0
export const WS_BODY_PROTOCOL_VERSION_HEARTBEAT = 1
export const WS_BODY_PROTOCOL_VERSION_DEFLATE = 2
export const WS_BODY_PROTOCOL_VERSION_BROTLI = 3
// const OP_HANDSHAKE = 0
// const OP_HANDSHAKE_REPLY = 1
const OP_HEARTBEAT = 2
const OP_HEARTBEAT_REPLY = 3
// const OP_SEND_MSG = 4
const OP_SEND_MSG_REPLY = 5
// const OP_DISCONNECT_REPLY = 6
const OP_AUTH = 7
const OP_AUTH_REPLY = 8
// const OP_RAW = 9
// const OP_PROTO_READY = 10
// const OP_PROTO_FINISH = 11
// const OP_CHANGE_ROOM = 12
// const OP_CHANGE_ROOM_REPLY = 13
// const OP_REGISTER = 14
// const OP_REGISTER_REPLY = 15
// const OP_UNREGISTER = 16
// const OP_UNREGISTER_REPLY = 17
export const OP_HANDSHAKE = 0
export const OP_HANDSHAKE_REPLY = 1
export const OP_HEARTBEAT = 2
export const OP_HEARTBEAT_REPLY = 3
export const OP_SEND_MSG = 4
export const OP_SEND_MSG_REPLY = 5
export const OP_DISCONNECT_REPLY = 6
export const OP_AUTH = 7
export const OP_AUTH_REPLY = 8
export const OP_RAW = 9
export const OP_PROTO_READY = 10
export const OP_PROTO_FINISH = 11
export const OP_CHANGE_ROOM = 12
export const OP_CHANGE_ROOM_REPLY = 13
export const OP_REGISTER = 14
export const OP_REGISTER_REPLY = 15
export const OP_UNREGISTER = 16
export const OP_UNREGISTER_REPLY = 17
// B站业务自定义OP
// const MinBusinessOp = 1000
// const MaxBusinessOp = 10000
// export const MinBusinessOp = 1000
// export const MaxBusinessOp = 10000
const AUTH_REPLY_CODE_OK = 0
// const AUTH_REPLY_CODE_TOKEN_ERROR = -101
export const AUTH_REPLY_CODE_OK = 0
export const AUTH_REPLY_CODE_TOKEN_ERROR = -101
const HEARTBEAT_INTERVAL = 10 * 1000
const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + (5 * 1000)
@@ -42,14 +41,9 @@ const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + (5 * 1000)
let textEncoder = new TextEncoder()
let textDecoder = new TextDecoder()
export default class ChatClientDirect {
constructor(roomId) {
// 调用initRoom后初始化如果失败使用这里的默认值
this.roomId = roomId
this.roomOwnerUid = 0
this.hostServerList = [
{ host: "broadcastlv.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244 }
]
export default class ChatClientOfficialBase {
constructor() {
this.CMD_CALLBACK_MAP = {}
this.onAddText = null
this.onAddGift = null
@@ -58,6 +52,9 @@ export default class ChatClientDirect {
this.onDelSuperChat = null
this.onUpdateTranslation = null
this.onFatalError = null
this.needInitRoom = true
this.websocket = null
this.retryCount = 0
this.isDestroying = false
@@ -65,8 +62,7 @@ export default class ChatClientDirect {
this.receiveTimeoutTimerId = null
}
async start() {
await this.initRoom()
start() {
this.wsConnect()
}
@@ -78,23 +74,16 @@ export default class ChatClientDirect {
}
async initRoom() {
let res
try {
res = (await axios.get('/api/room_info', { params: {
roomId: this.roomId
} })).data
} catch {
return
}
this.roomId = res.roomId
this.roomOwnerUid = res.ownerUid
if (res.hostServerList.length !== 0) {
this.hostServerList = res.hostServerList
}
throw Error('Not implemented')
}
makePacket(data, operation) {
let body = textEncoder.encode(JSON.stringify(data))
let body
if (typeof data === 'object') {
body = textEncoder.encode(JSON.stringify(data))
} else { // string
body = textEncoder.encode(data)
}
let header = new ArrayBuffer(HEADER_SIZE)
let headerView = new DataView(header)
headerView.setUint32(0, HEADER_SIZE + body.byteLength) // pack_len
@@ -106,29 +95,53 @@ export default class ChatClientDirect {
}
sendAuth() {
let authParams = {
uid: this.roomOwnerUid,
roomid: this.roomId,
protover: 3,
platform: 'web',
type: 2
}
this.websocket.send(this.makePacket(authParams, OP_AUTH))
throw Error('Not implemented')
}
wsConnect() {
async wsConnect() {
if (this.isDestroying) {
return
}
let hostServer = this.hostServerList[this.retryCount % this.hostServerList.length]
const url = `wss://${hostServer.host}:${hostServer.wss_port}/sub`
this.websocket = new WebSocket(url)
await this.onBeforeWsConnect()
if (this.isDestroying) {
return
}
this.websocket = new WebSocket(this.getWsUrl())
this.websocket.binaryType = 'arraybuffer'
this.websocket.onopen = this.onWsOpen.bind(this)
this.websocket.onclose = this.onWsClose.bind(this)
this.websocket.onmessage = this.onWsMessage.bind(this)
}
async onBeforeWsConnect() {
if (!this.needInitRoom) {
return
}
let res
try {
res = await this.initRoom()
} catch (e) {
res = false
console.error('initRoom exception:', e)
if (e instanceof chat.ChatClientFatalError && this.onFatalError) {
this.onFatalError(e)
}
}
if (!res) {
this.onWsClose()
throw Error('initRoom failed')
}
this.needInitRoom = false
}
getWsUrl() {
throw Error('Not implemented')
}
onWsOpen() {
this.sendAuth()
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), HEARTBEAT_INTERVAL)
@@ -179,7 +192,14 @@ export default class ChatClientDirect {
}
this.retryCount++
console.warn('掉线重连中', this.retryCount)
window.setTimeout(this.wsConnect.bind(this), 1000)
window.setTimeout(this.wsConnect.bind(this), this.getReconnectInterval())
}
getReconnectInterval() {
return Math.min(
1000 + ((this.retryCount - 1) * 2000),
10 * 1000
)
}
onWsMessage(event) {
@@ -248,6 +268,10 @@ export default class ChatClientDirect {
// 压缩过的先解压
body = BrotliDecode(body)
this.parseWsMessage(body)
} else if (ver == WS_BODY_PROTOCOL_VERSION_DEFLATE) {
// web端已经不用zlib压缩了但是开放平台会用
body = inflate(body)
this.parseWsMessage(body)
} else {
// 没压缩过的直接反序列化
if (body.length !== 0) {
@@ -267,7 +291,7 @@ export default class ChatClientDirect {
body = JSON.parse(textDecoder.decode(body))
if (body.code !== AUTH_REPLY_CODE_OK) {
console.error('认证响应错误body=', body)
// 这里应该重新获取token再重连的但前端没有用到token所以不重新init了
this.needInitRoom = true
this.discardWebsocket()
throw new Error('认证响应错误')
}
@@ -288,148 +312,9 @@ export default class ChatClientDirect {
if (pos != -1) {
cmd = cmd.substr(0, pos)
}
let callback = CMD_CALLBACK_MAP[cmd]
let callback = this.CMD_CALLBACK_MAP[cmd]
if (callback) {
callback.call(this, command)
}
}
async danmuMsgCallback(command) {
if (!this.onAddText) {
return
}
let info = command.info
let roomId, medalLevel
if (info[3]) {
roomId = info[3][3]
medalLevel = info[3][0]
} else {
roomId = medalLevel = 0
}
let uid = info[2][0]
let isAdmin = info[2][2]
let privilegeType = info[7]
let authorType
if (uid === this.roomOwnerUid) {
authorType = 3
} else if (isAdmin) {
authorType = 2
} else if (privilegeType !== 0) {
authorType = 1
} else {
authorType = 0
}
let textEmoticons = this.parseTextEmoticons(info)
let data = {
avatarUrl: await avatar.getAvatarUrl(uid),
timestamp: info[0][4] / 1000,
authorName: info[2][1],
authorType: authorType,
content: info[1],
privilegeType: privilegeType,
isGiftDanmaku: Boolean(info[0][9]),
authorLevel: info[4][0],
isNewbie: info[2][5] < 10000,
isMobileVerified: Boolean(info[2][6]),
medalLevel: roomId === this.roomId ? medalLevel : 0,
id: getUuid4Hex(),
translation: '',
emoticon: info[0][13].url || null,
textEmoticons: textEmoticons,
}
this.onAddText(data)
}
parseTextEmoticons(info) {
try {
let modeInfo = info[0][15]
let extra = JSON.parse(modeInfo.extra)
if (!extra.emots) {
return []
}
let res = Object.values(extra.emots).map(emoticon => [emoticon.descript, emoticon.url])
return res
} catch {
return []
}
}
sendGiftCallback(command) {
if (!this.onAddGift) {
return
}
let data = command.data
if (data.coin_type !== 'gold') { // 丢人
return
}
data = {
id: getUuid4Hex(),
avatarUrl: avatar.processAvatarUrl(data.face),
timestamp: data.timestamp,
authorName: data.uname,
totalCoin: data.total_coin,
giftName: data.giftName,
num: data.num
}
this.onAddGift(data)
}
async guardBuyCallback(command) {
if (!this.onAddMember) {
return
}
let data = command.data
data = {
id: getUuid4Hex(),
avatarUrl: await avatar.getAvatarUrl(data.uid),
timestamp: data.start_time,
authorName: data.username,
privilegeType: data.guard_level
}
this.onAddMember(data)
}
superChatMessageCallback(command) {
if (!this.onAddSuperChat) {
return
}
let data = command.data
data = {
id: data.id.toString(),
avatarUrl: avatar.processAvatarUrl(data.user_info.face),
timestamp: data.start_time,
authorName: data.user_info.uname,
price: data.price,
content: data.message,
translation: ''
}
this.onAddSuperChat(data)
}
superChatMessageDeleteCallback(command) {
if (!this.onDelSuperChat) {
return
}
let ids = []
for (let id of command.data.ids) {
ids.push(id.toString())
}
this.onDelSuperChat({ ids })
}
}
const CMD_CALLBACK_MAP = {
DANMU_MSG: ChatClientDirect.prototype.danmuMsgCallback,
SEND_GIFT: ChatClientDirect.prototype.sendGiftCallback,
GUARD_BUY: ChatClientDirect.prototype.guardBuyCallback,
SUPER_CHAT_MESSAGE: ChatClientDirect.prototype.superChatMessageCallback,
SUPER_CHAT_MESSAGE_DELETE: ChatClientDirect.prototype.superChatMessageDeleteCallback
}

View File

@@ -1,3 +1,5 @@
import * as chat from '.'
const COMMAND_HEARTBEAT = 0
const COMMAND_JOIN_ROOM = 1
const COMMAND_ADD_TEXT = 2
@@ -6,6 +8,7 @@ const COMMAND_ADD_MEMBER = 4
const COMMAND_ADD_SUPER_CHAT = 5
const COMMAND_DEL_SUPER_CHAT = 6
const COMMAND_UPDATE_TRANSLATION = 7
const COMMAND_FATAL_ERROR = 8
// const CONTENT_TYPE_TEXT = 0
const CONTENT_TYPE_EMOTICON = 1
@@ -13,8 +16,8 @@ const CONTENT_TYPE_EMOTICON = 1
const RECEIVE_TIMEOUT = 15 * 1000
export default class ChatClientRelay {
constructor(roomId, autoTranslate) {
this.roomId = roomId
constructor(roomKey, autoTranslate) {
this.roomKey = roomKey
this.autoTranslate = autoTranslate
this.onAddText = null
@@ -24,6 +27,8 @@ export default class ChatClientRelay {
this.onDelSuperChat = null
this.onUpdateTranslation = null
this.onFatalError = null
this.websocket = null
this.retryCount = 0
this.isDestroying = false
@@ -54,11 +59,10 @@ export default class ChatClientRelay {
}
onWsOpen() {
this.retryCount = 0
this.websocket.send(JSON.stringify({
cmd: COMMAND_JOIN_ROOM,
data: {
roomId: this.roomId,
roomKey: this.roomKey,
config: {
autoTranslate: this.autoTranslate
}
@@ -95,7 +99,14 @@ export default class ChatClientRelay {
return
}
console.warn(`掉线重连中${++this.retryCount}`)
window.setTimeout(this.wsConnect.bind(this), 1000)
window.setTimeout(this.wsConnect.bind(this), this.getReconnectInterval())
}
getReconnectInterval() {
return Math.min(
1000 + ((this.retryCount - 1) * 2000),
10 * 1000
)
}
onWsMessage(event) {
@@ -122,14 +133,15 @@ export default class ChatClientRelay {
emoticon = contentTypeParams[0]
}
let content = data[4]
data = {
avatarUrl: data[0],
timestamp: data[1],
authorName: data[2],
authorType: data[3],
content: data[4],
content: content,
privilegeType: data[5],
isGiftDanmaku: Boolean(data[6]),
isGiftDanmaku: Boolean(data[6]) || chat.isGiftDanmakuByContent(content),
authorLevel: data[7],
isNewbie: Boolean(data[8]),
isMobileVerified: Boolean(data[9]),
@@ -137,7 +149,6 @@ export default class ChatClientRelay {
id: data[11],
translation: data[12],
emoticon: emoticon,
textEmoticons: data[15],
}
this.onAddText(data)
break
@@ -177,6 +188,19 @@ export default class ChatClientRelay {
this.onUpdateTranslation(data)
break
}
case COMMAND_FATAL_ERROR: {
if (!this.onFatalError) {
break
}
let error = new chat.ChatClientFatalError(data.type, data.msg)
this.onFatalError(error)
break
}
}
// 至少成功处理1条消息
if (cmd !== COMMAND_FATAL_ERROR) {
this.retryCount = 0
}
}
}

View File

@@ -1,6 +1,6 @@
import { getUuid4Hex } from '@/utils'
import * as constants from '@/components/ChatRenderer/constants'
import * as avatar from './avatar'
import * as chat from '.'
const NAMES = [
'光羊',
@@ -104,7 +104,7 @@ const MESSAGE_GENERATORS = [
type: constants.MESSAGE_TYPE_TEXT,
message: {
...randGuardInfo(),
avatarUrl: avatar.DEFAULT_AVATAR_URL,
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
content: randomChoose(CONTENTS),
@@ -116,7 +116,6 @@ const MESSAGE_GENERATORS = [
id: getUuid4Hex(),
translation: '',
emoticon: null,
textEmoticons: [],
}
}
}
@@ -129,7 +128,7 @@ const MESSAGE_GENERATORS = [
type: constants.MESSAGE_TYPE_TEXT,
message: {
...randGuardInfo(),
avatarUrl: avatar.DEFAULT_AVATAR_URL,
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
content: '',
@@ -141,7 +140,6 @@ const MESSAGE_GENERATORS = [
id: getUuid4Hex(),
translation: '',
emoticon: randomChoose(EMOTICONS),
textEmoticons: [],
}
}
}
@@ -155,7 +153,7 @@ const MESSAGE_GENERATORS = [
message: {
...randomChoose(GIFT_INFO_LIST),
id: getUuid4Hex(),
avatarUrl: avatar.DEFAULT_AVATAR_URL,
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
num: 1
@@ -171,7 +169,7 @@ const MESSAGE_GENERATORS = [
type: constants.MESSAGE_TYPE_SUPER_CHAT,
message: {
id: getUuid4Hex(),
avatarUrl: avatar.DEFAULT_AVATAR_URL,
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
price: randomChoose(SC_PRICES),
@@ -189,7 +187,7 @@ const MESSAGE_GENERATORS = [
type: constants.MESSAGE_TYPE_MEMBER,
message: {
id: getUuid4Hex(),
avatarUrl: avatar.DEFAULT_AVATAR_URL,
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
privilegeType: randInt(1, 3)
@@ -240,6 +238,8 @@ export default class ChatClientTest {
this.onDelSuperChat = null
this.onUpdateTranslation = null
this.onFatalError = null
this.timerId = null
}

View File

@@ -1,28 +0,0 @@
import axios from 'axios'
export const DEFAULT_AVATAR_URL = '//static.hdslb.com/images/member/noface.gif'
export function processAvatarUrl(avatarUrl) {
// 去掉协议兼容HTTP、HTTPS
let m = avatarUrl.match(/(?:https?:)?(.*)/)
if (m) {
avatarUrl = m[1]
}
// 缩小图片加快传输
if (!avatarUrl.endsWith('noface.gif')) {
avatarUrl += '@48w_48h'
}
return avatarUrl
}
export async function getAvatarUrl(uid) {
let res
try {
res = (await axios.get('/api/avatar_url', { params: {
uid: uid
} })).data
} catch {
return DEFAULT_AVATAR_URL
}
return res.avatarUrl
}

View File

@@ -0,0 +1,73 @@
import axios from 'axios'
export const FATAL_ERROR_TYPE_AUTH_CODE_ERROR = 1
export class ChatClientFatalError extends Error {
constructor(type, message) {
super(message)
this.type = type
}
}
export const DEFAULT_AVATAR_URL = '//static.hdslb.com/images/member/noface.gif'
export function processAvatarUrl(avatarUrl) {
// 去掉协议兼容HTTP、HTTPS
let m = avatarUrl.match(/(?:https?:)?(.*)/)
if (m) {
avatarUrl = m[1]
}
return avatarUrl
}
export async function getAvatarUrl(uid, username) {
let res
try {
res = (await axios.get('/api/avatar_url', { params: {
uid: uid,
username: username
} })).data
} catch {
return DEFAULT_AVATAR_URL
}
return res.avatarUrl
}
export async function getTextEmoticons() {
let res
try {
res = (await axios.get('/api/text_emoticon_mappings')).data
} catch {
return []
}
return res.textEmoticons
}
// 开放平台接口不会发送是否是礼物弹幕,只能用内容判断了
const GIFT_DANMAKU_CONTENTS = new Set([
// 红包
'老板大气!点点红包抽礼物',
'老板大气!点点红包抽礼物!',
'点点红包,关注主播抽礼物~',
'喜欢主播加关注,点点红包抽礼物',
'红包抽礼物,开启今日好运!',
'中奖喷雾!中奖喷雾!',
// 节奏风暴
'前方高能预警,注意这不是演习',
'我从未见过如此厚颜无耻之人',
'那万一赢了呢',
'你们城里人真会玩',
'左舷弹幕太薄了',
'要优雅,不要污',
'我选择狗带',
'可爱即正义~~',
'糟了,是心动的感觉!',
'这个直播间已经被我们承包了!',
'妈妈问我为什么跪着看直播 w(゚Д゚)w',
'你们对力量一无所知~( ̄▽ ̄)~',
// 好像花式夸夸还有,不想花钱收集内容了
])
export function isGiftDanmakuByContent(content) {
return GIFT_DANMAKU_CONTENTS.has(content)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -5,7 +5,7 @@
</template>
<script>
import * as avatar from '@/api/chat/avatar'
import * as chat from '@/api/chat'
export default {
name: 'ImgShadow',
@@ -26,8 +26,8 @@ export default {
},
methods: {
onLoadError() {
if (this.showImgUrl !== avatar.DEFAULT_AVATAR_URL) {
this.showImgUrl = avatar.DEFAULT_AVATAR_URL
if (this.showImgUrl !== chat.DEFAULT_AVATAR_URL) {
this.showImgUrl = chat.DEFAULT_AVATAR_URL
}
}
}

View File

@@ -9,9 +9,15 @@ export default {
home: {
roomIdEmpty: "Room ID can't be empty",
roomIdInteger: 'Room ID must be positive integer',
authCodeEmpty: "Identity code can't be empty",
authCodeFormatError: 'Identity code format error',
useAuthCodeWarning: 'Please prioritize the identity code',
general: 'General',
room: 'Room',
roomId: 'Room ID',
authCode: 'Identity code',
howToGetAuthCode: 'How to get identity code',
showDanmaku: 'Show messages',
showGift: 'Show Super Chats',
showGiftName: 'Show gift name',
@@ -45,6 +51,7 @@ export default {
addEmoticon: 'Add emote',
emoticonFileTooLarge: 'File size is too large. Max size is 1MB',
urlTooLong: 'The room URL is too long, and will be truncated by Livehime (but not by OBS)',
roomUrl: 'Room URL',
enterRoom: 'Enter room',
enterTestRoom: 'Enter test room',
@@ -130,8 +137,9 @@ export default {
},
help: {
help: 'Help',
p1: '1. Copy the room ID from the Bilibili live room webpage',
p2: '2. Enter the room ID into the room ID on the home page. Copy the room URL',
p1_1: '1. Copy the identity code (身份码) from this webpage:',
p1_2: '. NOTE: DO NOT refresh the identity code, unless it is leaked. Once you refresh the identity code, the old one will be invalid',
p2: '2. Enter the identity code into the room configuration on the home page. Copy the room URL',
p3: '3. Generate styles with the style generator. Copy the CSS',
p4: '4. Add browser source in OBS',
p5: '5. Enter the previously copied room URL at URL, and enter the previously copied CSS at custom CSS'

View File

@@ -9,9 +9,15 @@ export default {
home: {
roomIdEmpty: 'ルームのIDを空白にすることはできません',
roomIdInteger: 'ルームは正の整数でなければなりません',
authCodeEmpty: 'アイデンティティコードを空白にすることはできません',
authCodeFormatError: 'アイデンティティコードの形式エラー',
useAuthCodeWarning: 'アイデンティティコードを優先的に使用してください',
general: '常規',
room: 'ルーム',
roomId: 'ルームID',
authCode: 'アイデンティティコード',
howToGetAuthCode: 'アイデンティティコードの取得方法',
showDanmaku: 'コメントを表示する',
showGift: 'スーパーチャットと新メンバーを表示する',
showGiftName: 'ギフト名を表示する',
@@ -45,6 +51,7 @@ export default {
addEmoticon: 'スタンプを追加',
emoticonFileTooLarge: 'ファイルサイズが大きすぎます。最大サイズは1MBです',
urlTooLong: 'ルームのURLが長すぎて、直播姬によって切り詰められますただし、OBSでは切り詰められません',
roomUrl: 'ルームのURL',
enterRoom: 'ルームに入る',
enterTestRoom: 'テストルームに入る',
@@ -130,8 +137,9 @@ export default {
},
help: {
help: 'ヘルプ',
p1: '1. ビリビリの生放送ウェブから生放送ルームIDをこぴーする',
p2: '2. ホームページでコピーしたIDを入力し、ルームのURLをこぴーする',
p1_1: '1. このウェブページからアイデンティティコード(身份码)をコピーして:',
p1_2: '。注意:アイデンティティコードは漏洩していない限り、更新しないでください。アイデンティティコードを更新すると、古いコードは無効になります',
p2: '2. ホームページに先ほどコピーしたアイデンティティコードを入力して、ルームのURLをこぴーする',
p3: '3. スタイルジェネレータでお好みのコメント様子を選び、出力したCSSをコピーする',
p4: '4. OBSでブラウザを新規作成する',
p5: '5. プロパティでこぴーしたURLを入力し、カスタムCSSでスタイルジェネレータのCSSを入力する'

View File

@@ -9,9 +9,15 @@ export default {
home: {
roomIdEmpty: '房间ID不能为空',
roomIdInteger: '房间ID必须为正整数',
authCodeEmpty: '身份码不能为空',
authCodeFormatError: '身份码格式错误',
useAuthCodeWarning: '请优先使用身份码',
general: '常规',
room: '房间',
roomId: '房间ID',
authCode: '身份码',
howToGetAuthCode: '如何获取身份码',
showDanmaku: '显示弹幕',
showGift: '显示打赏和新舰长',
showGiftName: '显示礼物名',
@@ -45,6 +51,7 @@ export default {
addEmoticon: '添加表情',
emoticonFileTooLarge: '文件尺寸太大最大1MB',
urlTooLong: '房间URL太长了会被直播姬截断OBS不会',
roomUrl: '房间URL',
enterRoom: '进入房间',
enterTestRoom: '进入测试房间',
@@ -130,8 +137,9 @@ export default {
},
help: {
help: '帮助',
p1: '1. 从B站直播间网页复制房间ID',
p2: '2. 把房间ID输入到首页的房间ID复制房间URL',
p1_1: '1. 从这个页面复制身份码:',
p1_2: '。注意:不要刷新身份码,除非你的身份码泄露了,因为刷新身份码会使旧的身份码失效',
p2: '2. 把身份码输入到首页的房间配置复制房间URL',
p3: '3. 使用样式生成器生成样式复制CSS',
p4: '4. 在OBS中添加浏览器源',
p5: '5. URL处输入之前复制的房间URL自定义CSS处输入之前复制的CSS'

View File

@@ -3,7 +3,7 @@
<div v-show="isMobile && !hideSidebar" class="drawer-bg" @click="hideSidebar = true"></div>
<el-aside width="230px" class="sidebar-container" :class="{ 'hide-sidebar': hideSidebar }">
<div class="logo-container">
<router-link to="/">
<router-link :to="{ name: 'home' }">
<img src="@/assets/img/logo.png" class="sidebar-logo">
<h1 class="sidebar-title">blivechat</h1>
</router-link>

View File

@@ -1,8 +1,8 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import {
Aside, Autocomplete, Badge, Button, ButtonGroup, Card, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
Input, Main, Menu, MenuItem, Message, Option, OptionGroup, Radio, RadioGroup, Row, Select, Scrollbar,
Alert, Aside, Autocomplete, Badge, Button, ButtonGroup, Card, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
Input, Link, Main, Menu, MenuItem, Message, Option, OptionGroup, Radio, RadioGroup, Row, Select, Scrollbar,
Slider, Submenu, Switch, Table, TableColumn, TabPane, Tabs, Tooltip
} from 'element-ui'
import axios from 'axios'
@@ -20,6 +20,7 @@ axios.defaults.timeout = 10 * 1000
Vue.use(VueRouter)
// 初始化element
Vue.use(Alert)
Vue.use(Aside)
Vue.use(Autocomplete)
Vue.use(Badge)
@@ -34,6 +35,7 @@ Vue.use(Form)
Vue.use(FormItem)
Vue.use(Image)
Vue.use(Input)
Vue.use(Link)
Vue.use(Main)
Vue.use(Menu)
Vue.use(MenuItem)
@@ -65,7 +67,7 @@ const router = new VueRouter({
path: '/',
component: Layout,
children: [
{ path: '', component: Home },
{ path: '', name: 'home', component: Home },
{ path: 'stylegen', name: 'stylegen', component: StyleGenerator },
{ path: 'help', name: 'help', component: Help }
]
@@ -77,15 +79,22 @@ const router = new VueRouter({
props: route => ({ strConfig: route.query })
},
{
path: '/room/:roomId',
path: '/room/:roomKeyValue',
name: 'room',
component: Room,
props(route) {
let roomId = parseInt(route.params.roomId)
if (isNaN(roomId)) {
roomId = null
let roomKeyType = parseInt(route.query.roomKeyType) || 1
if (roomKeyType < 1 || roomKeyType > 2) {
roomKeyType = 1
}
return { roomId, strConfig: route.query }
let roomKeyValue = route.params.roomKeyValue
if (roomKeyType === 1) {
roomKeyValue = parseInt(roomKeyValue) || null
} else {
roomKeyValue = roomKeyValue || null
}
return { roomKeyType, roomKeyValue, strConfig: route.query }
}
},
{ path: '*', component: NotFound }

View File

@@ -1,7 +1,7 @@
<template>
<div>
<h1>{{ $t('help.help') }}</h1>
<p>{{ $t('help.p1') }}</p>
<p>{{ $t('help.p1_1') }} <a href="https://play-live.bilibili.com/" target="_blank">https://play-live.bilibili.com/</a> {{ $t('help.p1_2') }}</p>
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-1.png"></el-image></p>
<p>{{ $t('help.p2') }}</p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-2.png"></el-image></p>

View File

@@ -1,17 +1,60 @@
<template>
<div>
<p>
<el-form :model="form" ref="form" label-width="150px" :rules="{
roomId: [
{required: true, message: $t('home.roomIdEmpty'), trigger: 'blur'},
{type: 'integer', min: 1, message: $t('home.roomIdInteger'), trigger: 'blur'}
]
}">
<el-form :model="form" ref="form" label-width="150px">
<el-tabs type="border-card">
<el-tab-pane :label="$t('home.general')">
<el-form-item :label="$t('home.roomId')" required prop="roomId">
<el-input v-model.number="form.roomId" type="number" min="1"></el-input>
<template v-if="form.roomKeyType === 1">
<p>
<el-alert :title="$t('home.useAuthCodeWarning')" type="warning" show-icon :closable="false"></el-alert>
</p>
<el-form-item
:label="$t('home.room')" prop="roomId" :rules="[
{ required: true, message: $t('home.roomIdEmpty') },
{ type: 'integer', min: 1, message: $t('home.roomIdInteger') }
]"
>
<el-row>
<el-col :span="6">
<el-select v-model="form.roomKeyType" style="width: 100%">
<el-option :label="$t('home.authCode')" :value="2"></el-option>
<el-option :label="$t('home.roomId')" :value="1"></el-option>
</el-select>
</el-col>
<el-col :span="18">
<el-input v-model.number="form.roomId" type="number" min="1"></el-input>
</el-col>
</el-row>
</el-form-item>
</template>
<el-form-item v-else-if="form.roomKeyType === 2"
:label="$t('home.room')" prop="authCode" :rules="[
{ required: true, message: $t('home.authCodeEmpty') },
{ pattern: /^[0-9A-Z]{12,14}$/, message: $t('home.authCodeFormatError') }
]"
>
<el-row>
<el-col :span="6">
<el-select v-model="form.roomKeyType" style="width: 100%">
<el-option :label="$t('home.authCode')" :value="2"></el-option>
<el-option :label="$t('home.roomId')" :value="1"></el-option>
</el-select>
</el-col>
<el-col :span="18">
<el-tooltip placement="top-start">
<div slot="content">
<!-- 不知道为什么router-link获取不到$router还是用el-link了不过会有一次丑陋的刷新 -->
<el-link
type="primary" :href="$router.resolve({ name: 'help' }).href"
>{{ $t('home.howToGetAuthCode') }}</el-link>
</div>
<el-input v-model.number="form.authCode"></el-input>
</el-tooltip>
</el-col>
</el-row>
</el-form-item>
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.showDanmaku')">
@@ -149,6 +192,9 @@
<p>
<el-card>
<el-form :model="form" label-width="150px">
<p v-if="obsRoomUrl.length > 1024">
<el-alert :title="$t('home.urlTooLong')" type="warning" show-icon :closable="false"></el-alert>
</p>
<el-form-item :label="$t('home.roomUrl')">
<el-input ref="roomUrlInput" readonly :value="obsRoomUrl" style="width: calc(100% - 8em); margin-right: 1em;"></el-input>
<el-button type="primary" icon="el-icon-copy-document" @click="copyUrl"></el-button>
@@ -184,13 +230,25 @@ export default {
},
form: {
...chatConfig.getLocalConfig(),
roomId: parseInt(window.localStorage.roomId || '1')
}
roomKeyType: parseInt(window.localStorage.roomKeyType || '2'),
roomId: parseInt(window.localStorage.roomId || '1'),
authCode: window.localStorage.authCode || '',
},
// 因为$refs.form.validate是异步的所以不能直接用计算属性
// getUnvalidatedRoomUrl -> unvalidatedRoomUrl -> updateRoomUrl -> roomUrl
roomUrl: '',
}
},
computed: {
roomUrl() {
return this.getRoomUrl(false)
roomKeyValue() {
if (this.form.roomKeyType === 1) {
return this.form.roomId
} else {
return this.form.authCode
}
},
unvalidatedRoomUrl() {
return this.getUnvalidatedRoomUrl(false)
},
obsRoomUrl() {
if (this.roomUrl === '') {
@@ -205,13 +263,17 @@ export default {
}
},
watch: {
unvalidatedRoomUrl: 'updateRoomUrl',
roomUrl: _.debounce(function() {
window.localStorage.roomKeyType = this.form.roomKeyType
window.localStorage.roomId = this.form.roomId
window.localStorage.authCode = this.form.authCode
chatConfig.setLocalConfig(this.form)
}, 500)
},
mounted() {
this.updateServerConfig()
this.updateRoomUrl()
},
methods: {
async updateServerConfig() {
@@ -222,6 +284,18 @@ export default {
throw e
}
},
async updateRoomUrl() {
// 防止切换roomKeyType时校验的还是老规则
await this.$nextTick()
try {
await this.$refs.form.validate()
} catch {
this.roomUrl = ''
return
}
// 没有异步的校验规则,应该不需要考虑竞争条件
this.roomUrl = this.unvalidatedRoomUrl
},
addEmoticon() {
this.form.emoticons.push({
@@ -256,28 +330,48 @@ export default {
},
enterRoom() {
window.open(this.roomUrl, `room ${this.form.roomId}`, 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
window.open(this.roomUrl, `room ${this.roomKeyValue}`, 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
},
enterTestRoom() {
window.open(this.getRoomUrl(true), 'test room', 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
window.open(this.getUnvalidatedRoomUrl(true), 'test room', 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
},
getRoomUrl(isTestRoom) {
if (!isTestRoom && this.form.roomId === '') {
return ''
getUnvalidatedRoomUrl(isTestRoom) {
// 重要的字段放在前面,因为如果被截断就连接不了房间了
let frontFields = {
roomKeyType: this.form.roomKeyType
}
let backFields = {
lang: this.$i18n.locale,
emoticons: JSON.stringify(this.form.emoticons)
}
let ignoredNames = new Set(['roomId', 'authCode'])
let query = { ...frontFields }
for (let name in this.form) {
if (!(name in frontFields || name in backFields || ignoredNames.has(name))) {
query[name] = this.form[name]
}
}
Object.assign(query, backFields)
let query = {
...this.form,
emoticons: JSON.stringify(this.form.emoticons),
lang: this.$i18n.locale
}
delete query.roomId
// 去掉和默认值相同的字段缩短URL长度
query = Object.fromEntries(Object.entries(query).filter(
([name, value]) => {
let defaultValue = chatConfig.DEFAULT_CONFIG[name]
if (defaultValue === undefined) {
return true
}
if (typeof defaultValue === 'object') {
defaultValue = JSON.stringify(defaultValue)
}
return value !== defaultValue
}
))
let resolved
if (isTestRoom) {
resolved = this.$router.resolve({ name: 'test_room', query })
} else {
resolved = this.$router.resolve({ name: 'room', params: { roomId: this.form.roomId }, query })
resolved = this.$router.resolve({ name: 'room', params: { roomKeyValue: this.roomKeyValue }, query })
}
return `${window.location.protocol}//${window.location.host}${resolved.href}`
},
@@ -285,6 +379,7 @@ export default {
this.$refs.roomUrlInput.select()
document.execCommand('Copy')
},
exportConfig() {
let cfg = mergeConfig(this.form, chatConfig.DEFAULT_CONFIG)
download(JSON.stringify(cfg, null, 2), 'blivechat.json', 'application/json')
@@ -314,7 +409,9 @@ export default {
chatConfig.sanitizeConfig(cfg)
this.form = {
...cfg,
roomId: this.form.roomId
roomKeyType: this.form.roomKeyType,
roomId: this.form.roomId,
authCode: this.form.authCode
}
}
}

View File

@@ -8,8 +8,10 @@ import { mergeConfig, toBool, toInt } from '@/utils'
import * as trie from '@/utils/trie'
import * as pronunciation from '@/utils/pronunciation'
import * as chatConfig from '@/api/chatConfig'
import * as chat from '@/api/chat'
import ChatClientTest from '@/api/chat/ChatClientTest'
import ChatClientDirect from '@/api/chat/ChatClientDirect'
import ChatClientDirectWeb from '@/api/chat/ChatClientDirectWeb'
import ChatClientDirectOpenLive from '@/api/chat/ChatClientDirectOpenLive'
import ChatClientRelay from '@/api/chat/ChatClientRelay'
import ChatRenderer from '@/components/ChatRenderer'
import * as constants from '@/components/ChatRenderer/constants'
@@ -20,8 +22,12 @@ export default {
ChatRenderer
},
props: {
roomId: {
roomKeyType: {
type: Number,
default: 1
},
roomKeyValue: {
type: [Number, String],
default: null
},
strConfig: {
@@ -34,7 +40,7 @@ export default {
config: chatConfig.deepCloneDefaultConfig(),
chatClient: null,
pronunciationConverter: null,
textEmoticons: {}, // 官方的文本表情,运行时从弹幕消息收集
textEmoticons: [], // 官方的文本表情
}
},
computed: {
@@ -60,37 +66,53 @@ export default {
},
emoticonsTrie() {
let res = new trie.Trie()
for (let emoticon of this.config.emoticons) {
if (emoticon.keyword !== '' && emoticon.url !== '') {
res.set(emoticon.keyword, emoticon)
for (let emoticons of [this.config.emoticons, this.textEmoticons]) {
for (let emoticon of emoticons) {
if (emoticon.keyword !== '' && emoticon.url !== '') {
res.set(emoticon.keyword, emoticon)
}
}
}
for (let emoticon of Object.values(this.textEmoticons)) {
res.set(emoticon.keyword, emoticon)
}
return res
}
},
mounted() {
this.initConfig()
this.initChatClient()
if (this.config.giftUsernamePronunciation !== '') {
this.pronunciationConverter = new pronunciation.PronunciationConverter()
this.pronunciationConverter.loadDict(this.config.giftUsernamePronunciation)
if (document.visibilityState === 'visible') {
this.init()
} else {
// 当前窗口不可见延迟到可见时加载防止OBS中一次并发太多请求OBS中浏览器不可见时也会加载网页除非显式设置
document.addEventListener('visibilitychange', this.onVisibilityChange)
}
// 提示用户已加载
this.$message({
message: 'Loaded',
duration: '500'
})
},
beforeDestroy() {
document.removeEventListener('visibilitychange', this.onVisibilityChange)
if (this.chatClient) {
this.chatClient.stop()
}
},
methods: {
onVisibilityChange() {
if (document.visibilityState !== 'visible') {
return
}
document.removeEventListener('visibilitychange', this.onVisibilityChange)
this.init()
},
init() {
this.initConfig()
this.initChatClient()
this.initTextEmoticons()
if (this.config.giftUsernamePronunciation !== '') {
this.pronunciationConverter = new pronunciation.PronunciationConverter()
this.pronunciationConverter.loadDict(this.config.giftUsernamePronunciation)
}
// 提示用户已加载
this.$message({
message: 'Loaded',
duration: 500
})
},
initConfig() {
let locale = this.strConfig.lang
if (locale) {
@@ -138,13 +160,19 @@ export default {
}
},
initChatClient() {
if (this.roomId === null) {
if (this.roomKeyValue === null) {
this.chatClient = new ChatClientTest()
} else if (this.config.relayMessagesByServer) {
let roomKey = {
type: this.roomKeyType,
value: this.roomKeyValue
}
this.chatClient = new ChatClientRelay(roomKey, this.config.autoTranslate)
} else {
if (!this.config.relayMessagesByServer) {
this.chatClient = new ChatClientDirect(this.roomId)
if (this.roomKeyType === 1) {
this.chatClient = new ChatClientDirectWeb(this.roomKeyValue)
} else {
this.chatClient = new ChatClientRelay(this.roomId, this.config.autoTranslate)
this.chatClient = new ChatClientDirectOpenLive(this.roomKeyValue)
}
}
this.chatClient.onAddText = this.onAddText
@@ -153,8 +181,12 @@ export default {
this.chatClient.onAddSuperChat = this.onAddSuperChat
this.chatClient.onDelSuperChat = this.onDelSuperChat
this.chatClient.onUpdateTranslation = this.onUpdateTranslation
this.chatClient.onFatalError = this.onFatalError
this.chatClient.start()
},
async initTextEmoticons() {
this.textEmoticons = await chat.getTextEmoticons()
},
start() {
this.chatClient.start()
@@ -167,15 +199,6 @@ export default {
if (!this.config.showDanmaku || !this.filterTextMessage(data) || this.mergeSimilarText(data.content)) {
return
}
// 更新官方文本表情
for (let [keyword, url] of data.textEmoticons) {
if (!(keyword in this.textEmoticons)) {
let emoticon = { keyword, url }
this.$set(this.textEmoticons, keyword, emoticon)
}
}
let message = {
id: data.id,
type: constants.MESSAGE_TYPE_TEXT,
@@ -260,6 +283,18 @@ export default {
}
this.$refs.renderer.updateMessage(data.id, { translation: data.translation })
},
onFatalError(error) {
this.$message.error({
message: error.toString(),
duration: 10 * 1000
})
this.chatClient.stop()
if (error.type === chat.FATAL_ERROR_TYPE_AUTH_CODE_ERROR) {
// Read The Fucking Manual
this.$router.push({ name: 'help' })
}
},
filterTextMessage(data) {
if (this.config.blockGiftDanmaku && data.isGiftDanmaku) {
@@ -326,7 +361,7 @@ export default {
}
// 没有文本表情,只能是纯文本
if (this.config.emoticons.length === 0 && Object.keys(this.textEmoticons).length === 0) {
if (this.config.emoticons.length === 0 && this.textEmoticons.length === 0) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
text: data.content

90
main.py
View File

@@ -1,15 +1,20 @@
# -*- coding: utf-8 -*-
import argparse
import asyncio
import logging
import logging.handlers
import os
import signal
import sys
import webbrowser
from typing import *
import tornado.ioloop
import tornado.web
import api.chat
import api.main
import api.open_live
import config
import models.database
import services.avatar
@@ -20,23 +25,35 @@ import utils.request
logger = logging.getLogger(__name__)
routes = [
(r'/api/server_info', api.main.ServerInfoHandler),
(r'/api/emoticon', api.main.UploadEmoticonHandler),
(r'/api/chat', api.chat.ChatHandler),
(r'/api/room_info', api.chat.RoomInfoHandler),
(r'/api/avatar_url', api.chat.AvatarHandler),
(rf'{api.main.EMOTICON_BASE_URL}/(.*)', tornado.web.StaticFileHandler, {'path': api.main.EMOTICON_UPLOAD_PATH}),
(r'/(.*)', api.main.MainHandler, {'path': config.WEB_ROOT})
ROUTES = [
*api.main.ROUTES,
*api.chat.ROUTES,
*api.open_live.ROUTES,
*api.main.LAST_ROUTES,
]
server: Optional[tornado.httpserver.HTTPServer] = None
shut_down_event: Optional[asyncio.Event] = None
async def main():
if not init():
return 1
try:
await run()
finally:
await shut_down()
return 0
def init():
init_signal_handlers()
def main():
args = parse_args()
init_logging(args.debug)
logger.info('App started, initializing')
config.init()
utils.request.init()
@@ -48,7 +65,27 @@ def main():
update.check_update()
run_server(args.host, args.port, args.debug)
init_server(args.host, args.port, args.debug)
return server is not None
def init_signal_handlers():
global shut_down_event
shut_down_event = asyncio.Event()
signums = (signal.SIGINT, signal.SIGTERM)
try:
loop = asyncio.get_running_loop()
for signum in signums:
loop.add_signal_handler(signum, on_shut_down_signal)
except NotImplementedError:
# 不太安全但Windows只能用这个
for signum in signums:
signal.signal(signum, on_shut_down_signal)
def on_shut_down_signal(*_args):
shut_down_event.set()
def parse_args():
@@ -76,7 +113,7 @@ def init_logging(debug):
logging.getLogger('tornado.access').setLevel(logging.WARNING)
def run_server(host, port, debug):
def init_server(host, port, debug):
cfg = config.get_config()
if host is None:
host = cfg.host
@@ -84,13 +121,14 @@ def run_server(host, port, debug):
port = cfg.port
app = tornado.web.Application(
routes,
ROUTES,
websocket_ping_interval=10,
debug=debug,
autoreload=False
)
try:
app.listen(
global server
server = app.listen(
port,
host,
xheaders=cfg.tornado_xheaders,
@@ -105,8 +143,26 @@ def run_server(host, port, debug):
url = 'http://localhost/' if port == 80 else f'http://localhost:{port}/'
webbrowser.open(url)
logger.info('Server started: %s:%d', host, port)
tornado.ioloop.IOLoop.current().start()
async def run():
logger.info('Running event loop')
await shut_down_event.wait()
logger.info('Received shutdown signal')
async def shut_down():
logger.info('Closing server')
server.stop()
await server.close_all_connections()
logger.info('Closing websocket connections')
await services.chat.shut_down()
await utils.request.shut_down()
logger.info('App shut down')
if __name__ == '__main__':
main()
sys.exit(asyncio.run(main()))

View File

@@ -5,7 +5,6 @@ import datetime
import hashlib
import logging
import re
import urllib.parse
from typing import *
import aiohttp
@@ -42,12 +41,11 @@ def init():
global _avatar_url_cache, _task_queue
_avatar_url_cache = cachetools.TTLCache(cfg.avatar_cache_size, 10 * 60)
_task_queue = asyncio.Queue(cfg.fetch_avatar_max_queue_size)
asyncio.get_event_loop().create_task(_do_init())
asyncio.get_running_loop().create_task(_do_init())
async def _do_init():
fetchers = [
UserSpaceAvatarFetcher(5.5),
MedalAnchorAvatarFetcher(3),
UserCardAvatarFetcher(3),
GameUserCenterAvatarFetcher(3),
@@ -57,13 +55,24 @@ async def _do_init():
_avatar_fetchers = fetchers
async def get_avatar_url(user_id) -> str:
async def get_avatar_url(user_id, username) -> str:
avatar_url = await get_avatar_url_or_none(user_id)
if avatar_url is None:
avatar_url = DEFAULT_AVATAR_URL
avatar_url = get_default_avatar_url(user_id, username)
return avatar_url
def get_default_avatar_url(user_id=0, username=''):
if user_id != 0:
str_to_hash = str(user_id)
elif username != '':
str_to_hash = username
else:
return DEFAULT_AVATAR_URL
id_hash = hashlib.md5(str_to_hash.encode('utf-8')).hexdigest()
return f'//cravatar.cn/avatar/{id_hash}?s=256&d=robohash&f=y'
async def get_avatar_url_or_none(user_id) -> Optional[str]:
if user_id == 0:
return None
@@ -100,6 +109,8 @@ async def _refresh_avatar_cache_from_web(user_id):
def update_avatar_cache(user_id, avatar_url):
if user_id == 0:
return
_update_avatar_cache_in_memory(user_id, avatar_url)
_update_avatar_cache_in_database(user_id, avatar_url)
@@ -115,9 +126,6 @@ def process_avatar_url(avatar_url):
m = re.fullmatch(r'(?:https?:)?(.*)', avatar_url)
if m is not None:
avatar_url = m[1]
# 缩小图片加快传输
if not avatar_url.endswith('noface.gif'):
avatar_url += '@48w_48h'
return avatar_url
@@ -304,131 +312,6 @@ class AvatarFetcher:
self._on_availability_change()
class UserSpaceAvatarFetcher(AvatarFetcher):
# wbi密码表
WBI_KEY_INDEX_TABLE = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13
]
def __init__(self, query_interval):
super().__init__(query_interval)
# wbi鉴权口令
self._wbi_key = ''
async def _do_fetch(self, user_id) -> Optional[str]:
if self._wbi_key == '':
await self._refresh_wbi_key()
if self._wbi_key == '':
return None
try:
async with utils.request.http_session.get(
'https://api.bilibili.com/x/space/wbi/acc/info',
headers={
**utils.request.BILIBILI_COMMON_HEADERS,
'Origin': 'https://space.bilibili.com',
'Referer': f'https://space.bilibili.com/{user_id}/'
},
params=self._add_wbi_sign({'mid': user_id}),
) as r:
if r.status != 200:
logger.warning(
'UserSpaceAvatarFetcher failed to fetch avatar: status=%d %s uid=%d',
r.status, r.reason, user_id
)
if r.status == 412:
# 被B站ban了
self._cool_down(3 * 60)
await self._refresh_wbi_key()
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
code = data['code']
if code != 0:
logger.info(
'UserSpaceAvatarFetcher failed to fetch avatar: code=%d %s uid=%d',
code, data['message'], user_id
)
if code == -401:
# 被B站ban了
self._cool_down(3 * 60)
await self._refresh_wbi_key()
elif code == -403:
# 签名错误
self._wbi_key = ''
await self._refresh_wbi_key()
return None
return process_avatar_url(data['data']['face'])
async def _refresh_wbi_key(self):
wbi_key = await self._get_wbi_key()
if wbi_key != '':
self._wbi_key = wbi_key
async def _get_wbi_key(self):
try:
async with utils.request.http_session.get(
'https://api.bilibili.com/nav',
headers=utils.request.BILIBILI_COMMON_HEADERS,
) as r:
if r.status != 200:
logger.warning('UserSpaceAvatarFetcher failed to get wbi key: status=%d %s', r.status, r.reason)
return ''
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
logger.exception('UserSpaceAvatarFetcher failed to get wbi key:')
return ''
try:
wbi_img = data['data']['wbi_img']
img_key = wbi_img['img_url'].rpartition('/')[2].partition('.')[0]
sub_key = wbi_img['sub_url'].rpartition('/')[2].partition('.')[0]
except KeyError:
logger.warning('UserSpaceAvatarFetcher failed to get wbi key: data=%s', data)
return ''
shuffled_key = img_key + sub_key
wbi_key = []
for index in self.WBI_KEY_INDEX_TABLE:
if index < len(shuffled_key):
wbi_key.append(shuffled_key[index])
return ''.join(wbi_key)
def _add_wbi_sign(self, params: dict):
if self._wbi_key == '':
return params
wts = str(int(datetime.datetime.now().timestamp()))
params_to_sign = {**params, 'wts': wts}
# 按key字典序排序
params_to_sign = {
key: params_to_sign[key]
for key in sorted(params_to_sign.keys())
}
# 过滤一些字符
for key, value in params_to_sign.items():
value = ''.join(
ch
for ch in str(value)
if ch not in "!'()*"
)
params_to_sign[key] = value
str_to_sign = urllib.parse.urlencode(params_to_sign) + self._wbi_key
w_rid = hashlib.md5(str_to_sign.encode('utf-8')).hexdigest()
return {
**params,
'wts': wts,
'w_rid': w_rid
}
class MedalAnchorAvatarFetcher(AvatarFetcher):
async def _do_fetch(self, user_id) -> Optional[str]:
try:

View File

@@ -1,15 +1,16 @@
# -*- coding: utf-8 -*-
import asyncio
import base64
import binascii
import json
import enum
import logging
import uuid
from typing import *
import api.chat
import api.open_live as api_open_live
import blivedm.blivedm as blivedm
import blivedm.blivedm.models.pb as blivedm_pb
import blivedm.blivedm.models.open_live as dm_open_models
import blivedm.blivedm.models.web as dm_web_models
import blivedm.blivedm.utils as dm_utils
import config
import services.avatar
import services.translate
@@ -17,6 +18,29 @@ import utils.request
logger = logging.getLogger(__name__)
class RoomKeyType(enum.IntEnum):
ROOM_ID = 1
AUTH_CODE = 2
class RoomKey(NamedTuple):
"""内部用来标识一个房间,由客户端加入房间时传入"""
type: RoomKeyType
value: Union[int, str]
def __str__(self):
res = str(self.value)
if self.type == RoomKeyType.AUTH_CODE:
# 身份码要脱敏
res = '***' + res[-3:]
return res
__repr__ = __str__
# 用于类型标注的类型别名
LiveClientType = Union['WebLiveClient', 'OpenLiveClient']
# 到B站的连接管理
_live_client_manager: Optional['LiveClientManager'] = None
# 到客户端的连接管理
@@ -32,53 +56,194 @@ def init():
_live_msg_handler = LiveMsgHandler()
async def shut_down():
if client_room_manager is not None:
client_room_manager.shut_down()
if _live_client_manager is not None:
await _live_client_manager.shut_down()
class LiveClientManager:
"""管理到B站的连接"""
def __init__(self):
self._live_clients: Dict[int, LiveClient] = {}
self._live_clients: Dict[RoomKey, LiveClientType] = {}
self._close_client_futures: Set[asyncio.Future] = set()
def add_live_client(self, room_id):
if room_id in self._live_clients:
return
logger.info('room=%d creating live client', room_id)
self._live_clients[room_id] = live_client = LiveClient(room_id)
live_client.add_handler(_live_msg_handler)
asyncio.create_task(self._init_live_client(live_client))
logger.info('room=%d live client created, %d live clients', room_id, len(self._live_clients))
async def shut_down(self):
while len(self._live_clients) != 0:
room_key = next(iter(self._live_clients))
self.del_live_client(room_key)
async def _init_live_client(self, live_client: 'LiveClient'):
if not await live_client.init_room():
logger.warning('room=%d live client init failed', live_client.tmp_room_id)
self.del_live_client(live_client.tmp_room_id)
await asyncio.gather(*self._close_client_futures, return_exceptions=True)
def add_live_client(self, room_key: RoomKey):
if room_key in self._live_clients:
return
logger.info('room=%d (%d) live client init succeeded', live_client.tmp_room_id, live_client.room_id)
logger.info('room=%s creating live client', room_key)
self._live_clients[room_key] = live_client = self._create_live_client(room_key)
live_client.set_handler(_live_msg_handler)
# 直接启动吧这里不用管init_room失败的情况万一失败了会在on_client_stopped里删除掉这个客户端
live_client.start()
def del_live_client(self, room_id):
live_client = self._live_clients.pop(room_id, None)
logger.info('room=%s live client created, %d live clients', room_key, len(self._live_clients))
@staticmethod
def _create_live_client(room_key: RoomKey):
if room_key.type == RoomKeyType.ROOM_ID:
return WebLiveClient(room_key)
elif room_key.type == RoomKeyType.AUTH_CODE:
return OpenLiveClient(room_key)
raise ValueError(f'Unknown RoomKeyType={room_key.type}')
def del_live_client(self, room_key: RoomKey):
live_client = self._live_clients.pop(room_key, None)
if live_client is None:
return
logger.info('room=%d removing live client', room_id)
live_client.remove_handler(_live_msg_handler)
asyncio.create_task(live_client.stop_and_close())
logger.info('room=%d live client removed, %d live clients', room_id, len(self._live_clients))
client_room_manager.del_room(room_id)
logger.info('room=%s removing live client', room_key)
live_client.set_handler(None)
future = asyncio.create_task(live_client.stop_and_close())
self._close_client_futures.add(future)
future.add_done_callback(lambda _future: self._close_client_futures.discard(future))
logger.info('room=%s live client removed, %d live clients', room_key, len(self._live_clients))
client_room_manager.del_room(room_key)
class LiveClient(blivedm.BLiveClient):
RECONNECT_POLICY = dm_utils.make_linear_retry_policy(1, 2, 10)
class WebLiveClient(blivedm.BLiveClient):
HEARTBEAT_INTERVAL = 10
def __init__(self, room_id):
super().__init__(room_id, session=utils.request.http_session, heartbeat_interval=self.HEARTBEAT_INTERVAL)
def __init__(self, room_key: RoomKey):
assert room_key.type == RoomKeyType.ROOM_ID
super().__init__(
room_key.value,
uid=0,
session=utils.request.http_session,
heartbeat_interval=self.HEARTBEAT_INTERVAL,
)
self.set_reconnect_policy(RECONNECT_POLICY)
@property
def tmp_room_id(self):
"""初始化参数传入的房间IDroom_id可能改变这个不会变"""
return self._tmp_room_id
def room_key(self):
return RoomKey(RoomKeyType.ROOM_ID, self.tmp_room_id)
async def init_room(self):
await super().init_room()
res = await super().init_room()
if res:
logger.info('room=%s live client init succeeded, room_id=%d', self.room_key, self.room_id)
else:
logger.info('room=%s live client init with a downgrade, room_id=%d', self.room_key, self.room_id)
# 允许降级
return True
class OpenLiveClient(blivedm.OpenLiveClient):
HEARTBEAT_INTERVAL = 10
def __init__(self, room_key: RoomKey):
assert room_key.type == RoomKeyType.AUTH_CODE
cfg = config.get_config()
super().__init__(
access_key_id=cfg.open_live_access_key_id,
access_key_secret=cfg.open_live_access_key_secret,
app_id=cfg.open_live_app_id,
room_owner_auth_code=room_key.value,
session=utils.request.http_session,
heartbeat_interval=self.HEARTBEAT_INTERVAL,
)
self.set_reconnect_policy(RECONNECT_POLICY)
@property
def room_key(self):
return RoomKey(RoomKeyType.AUTH_CODE, self.room_owner_auth_code)
async def init_room(self):
res = await super().init_room()
if res:
logger.info('room=%s live client init succeeded, room_id=%d', self.room_key, self.room_id)
else:
logger.info('room=%s live client init failed', self.room_key)
return res
async def _start_game(self):
try:
data = await api_open_live.request_open_live_or_common_server(
api_open_live.START_GAME_OPEN_LIVE_URL,
api_open_live.START_GAME_COMMON_SERVER_URL,
{'code': self._room_owner_auth_code, 'app_id': self._app_id}
)
except api_open_live.TransportError:
logger.error('_start_game() failed')
return False
except api_open_live.BusinessError as e:
logger.warning('_start_game() failed')
if e.code == 7007:
# 身份码错误
# 让我看看是哪个混蛋把房间ID、UID当做身份码
logger.info('Auth code error! auth_code=%s', self._room_owner_auth_code)
room = client_room_manager.get_room(self.room_key)
if room is not None:
room.send_cmd_data(api.chat.Command.FATAL_ERROR, {
'type': api.chat.FatalErrorType.AUTH_CODE_ERROR,
'msg': str(e)
})
return False
return self._parse_start_game(data['data'])
async def _end_game(self):
if self._game_id in (None, ''):
return True
try:
await api_open_live.request_open_live_or_common_server(
api_open_live.END_GAME_OPEN_LIVE_URL,
api_open_live.END_GAME_COMMON_SERVER_URL,
{'app_id': self._app_id, 'game_id': self._game_id}
)
except api_open_live.TransportError:
logger.error('room=%d _end_game() failed', self.room_id)
return False
except api_open_live.BusinessError as e:
if e.code in (7000, 7003):
# 项目已经关闭了也算成功
return True
logger.warning('room=%d _end_game() failed', self.room_id)
return False
return True
async def _send_game_heartbeat(self):
if self._game_id in (None, ''):
logger.warning('game=%d _send_game_heartbeat() failed, game_id not found', self._game_id)
return False
# 保存一下防止await之后game_id改变
game_id = self._game_id
try:
await api_open_live.request_open_live_or_common_server(
api_open_live.GAME_HEARTBEAT_OPEN_LIVE_URL,
api_open_live.GAME_HEARTBEAT_COMMON_SERVER_URL,
{'game_id': game_id}
)
except api_open_live.TransportError:
logger.error('room=%d _send_game_heartbeat() failed', self.room_id)
return False
except api_open_live.BusinessError as e:
logger.warning('room=%d _send_game_heartbeat() failed', self.room_id)
if e.code == 7003 and self._game_id == game_id:
# 项目异常关闭,可能是心跳超时,需要重新开启项目
self._need_init_room = True
if self._websocket is not None and not self._websocket.closed:
await self._websocket.close()
return False
return True
@@ -88,75 +253,85 @@ class ClientRoomManager:
DELAY_DEL_ROOM_TIMEOUT = 10
def __init__(self):
self._rooms: Dict[int, ClientRoom] = {}
# room_id -> timer_handle
self._delay_del_timer_handles: Dict[int, asyncio.TimerHandle] = {}
self._rooms: Dict[RoomKey, ClientRoom] = {}
self._delay_del_timer_handles: Dict[RoomKey, asyncio.TimerHandle] = {}
def add_client(self, room_id, client: 'api.chat.ChatHandler'):
room = self._get_or_add_room(room_id)
def shut_down(self):
while len(self._rooms) != 0:
room_key = next(iter(self._rooms))
self.del_room(room_key)
for timer_handle in self._delay_del_timer_handles.values():
timer_handle.cancel()
self._delay_del_timer_handles.clear()
def add_client(self, room_key: RoomKey, client: 'api.chat.ChatHandler'):
room = self._get_or_add_room(room_key)
room.add_client(client)
self._clear_delay_del_timer(room_id)
self._clear_delay_del_timer(room_key)
def del_client(self, room_id, client: 'api.chat.ChatHandler'):
room = self.get_room(room_id)
def del_client(self, room_key: RoomKey, client: 'api.chat.ChatHandler'):
room = self.get_room(room_key)
if room is None:
return
room.del_client(client)
if room.client_count == 0:
self.delay_del_room(room_id, self.DELAY_DEL_ROOM_TIMEOUT)
self.delay_del_room(room_key, self.DELAY_DEL_ROOM_TIMEOUT)
def get_room(self, room_id):
return self._rooms.get(room_id, None)
def get_room(self, room_key: RoomKey):
return self._rooms.get(room_key, None)
def _get_or_add_room(self, room_id):
room = self._rooms.get(room_id, None)
def _get_or_add_room(self, room_key: RoomKey):
room = self._rooms.get(room_key, None)
if room is None:
logger.info('room=%d creating client room', room_id)
self._rooms[room_id] = room = ClientRoom(room_id)
logger.info('room=%d client room created, %d client rooms', room_id, len(self._rooms))
logger.info('room=%s creating client room', room_key)
self._rooms[room_key] = room = ClientRoom(room_key)
logger.info('room=%s client room created, %d client rooms', room_key, len(self._rooms))
_live_client_manager.add_live_client(room_id)
_live_client_manager.add_live_client(room_key)
return room
def del_room(self, room_id):
self._clear_delay_del_timer(room_id)
def del_room(self, room_key: RoomKey):
self._clear_delay_del_timer(room_key)
room = self._rooms.pop(room_id, None)
room = self._rooms.pop(room_key, None)
if room is None:
return
logger.info('room=%d removing client room', room_id)
logger.info('room=%s removing client room', room_key)
room.clear_clients()
logger.info('room=%d client room removed, %d client rooms', room_id, len(self._rooms))
logger.info('room=%s client room removed, %d client rooms', room_key, len(self._rooms))
_live_client_manager.del_live_client(room_id)
_live_client_manager.del_live_client(room_key)
def delay_del_room(self, room_id, timeout):
self._clear_delay_del_timer(room_id)
self._delay_del_timer_handles[room_id] = asyncio.get_running_loop().call_later(
timeout, self._on_delay_del_room, room_id
def delay_del_room(self, room_key: RoomKey, timeout):
self._clear_delay_del_timer(room_key)
self._delay_del_timer_handles[room_key] = asyncio.get_running_loop().call_later(
timeout, self._on_delay_del_room, room_key
)
def _clear_delay_del_timer(self, room_id):
timer_handle = self._delay_del_timer_handles.pop(room_id, None)
def _clear_delay_del_timer(self, room_key: RoomKey):
timer_handle = self._delay_del_timer_handles.pop(room_key, None)
if timer_handle is not None:
timer_handle.cancel()
def _on_delay_del_room(self, room_id):
self._delay_del_timer_handles.pop(room_id, None)
self.del_room(room_id)
def _on_delay_del_room(self, room_key: RoomKey):
self._delay_del_timer_handles.pop(room_key, None)
self.del_room(room_key)
class ClientRoom:
def __init__(self, room_id):
self._room_id = room_id
def __init__(self, room_key: RoomKey):
self._room_key = room_key
self._clients: List[api.chat.ChatHandler] = []
self._auto_translate_count = 0
@property
def room_id(self):
return self._room_id
def room_key(self) -> RoomKey:
return self._room_key
@property
def client_count(self):
@@ -167,11 +342,13 @@ class ClientRoom:
return self._auto_translate_count > 0
def add_client(self, client: 'api.chat.ChatHandler'):
logger.info('room=%d addding client %s', self._room_id, client.request.remote_ip)
logger.info('room=%s addding client %s', self._room_key, client.request.remote_ip)
self._clients.append(client)
if client.auto_translate:
self._auto_translate_count += 1
logger.info('room=%d added client %s, %d clients', self._room_id, client.request.remote_ip,
logger.info('room=%s added client %s, %d clients', self._room_key, client.request.remote_ip,
self.client_count)
def del_client(self, client: 'api.chat.ChatHandler'):
@@ -182,11 +359,13 @@ class ClientRoom:
return
if client.auto_translate:
self._auto_translate_count -= 1
logger.info('room=%d removed client %s, %d clients', self._room_id, client.request.remote_ip,
logger.info('room=%s removed client %s, %d clients', self._room_key, client.request.remote_ip,
self.client_count)
def clear_clients(self):
logger.info('room=%d clearing %d clients', self._room_id, self.client_count)
logger.info('room=%s clearing %d clients', self._room_key, self.client_count)
for client in self._clients:
client.close()
self._clients.clear()
@@ -204,111 +383,21 @@ class ClientRoom:
class LiveMsgHandler(blivedm.BaseHandler):
# 重新定义XXX_callback是为了减少对字段名的依赖防止B站改字段名
def __danmu_msg_callback(self, client: LiveClient, command: dict):
info = command['info']
dm_v2 = command.get('dm_v2', '')
def on_client_stopped(self, client: LiveClientType, exception: Optional[Exception]):
_live_client_manager.del_live_client(client.room_key)
proto: Optional[blivedm_pb.SimpleDm] = None
if dm_v2 != '':
try:
proto = blivedm_pb.SimpleDm.loads(base64.b64decode(dm_v2))
except (binascii.Error, KeyError, TypeError, ValueError):
pass
if proto is not None:
face = proto.user.face
else:
face = ''
if len(info[3]) != 0:
medal_level = info[3][0]
medal_room_id = info[3][3]
else:
medal_level = 0
medal_room_id = 0
message = blivedm.DanmakuMessage(
timestamp=info[0][4],
msg_type=info[0][9],
dm_type=info[0][12],
emoticon_options=info[0][13],
mode_info=info[0][15],
msg=info[1],
uid=info[2][0],
uname=info[2][1],
face=face,
admin=info[2][2],
urank=info[2][5],
mobile_verify=info[2][6],
medal_level=medal_level,
medal_room_id=medal_room_id,
user_level=info[4][0],
privilege_type=info[7],
)
return self._on_danmaku(client, message)
def __send_gift_callback(self, client: LiveClient, command: dict):
data = command['data']
message = blivedm.GiftMessage(
gift_name=data['giftName'],
num=data['num'],
uname=data['uname'],
face=data['face'],
uid=data['uid'],
timestamp=data['timestamp'],
coin_type=data['coin_type'],
total_coin=data['total_coin'],
)
return self._on_gift(client, message)
def __guard_buy_callback(self, client: LiveClient, command: dict):
data = command['data']
message = blivedm.GuardBuyMessage(
uid=data['uid'],
username=data['username'],
guard_level=data['guard_level'],
start_time=data['start_time'],
)
return self._on_buy_guard(client, message)
def __super_chat_message_callback(self, client: LiveClient, command: dict):
data = command['data']
message = blivedm.SuperChatMessage(
price=data['price'],
message=data['message'],
start_time=data['start_time'],
id=data['id'],
uid=data['uid'],
uname=data['user_info']['uname'],
face=data['user_info']['face'],
)
return self._on_super_chat(client, message)
_CMD_CALLBACK_DICT = {
**blivedm.BaseHandler._CMD_CALLBACK_DICT,
'DANMU_MSG': __danmu_msg_callback,
'SEND_GIFT': __send_gift_callback,
'GUARD_BUY': __guard_buy_callback,
'SUPER_CHAT_MESSAGE': __super_chat_message_callback
}
async def _on_danmaku(self, client: LiveClient, message: blivedm.DanmakuMessage):
def _on_danmaku(self, client: WebLiveClient, message: dm_web_models.DanmakuMessage):
asyncio.create_task(self.__on_danmaku(client, message))
async def __on_danmaku(self, client: LiveClient, message: blivedm.DanmakuMessage):
async def __on_danmaku(self, client: WebLiveClient, message: dm_web_models.DanmakuMessage):
avatar_url = message.face
if avatar_url != '':
services.avatar.update_avatar_cache_if_expired(message.uid, avatar_url)
else:
# 先异步调用再获取房间,因为返回时房间可能已经不存在了
avatar_url = await services.avatar.get_avatar_url(message.uid)
avatar_url = await services.avatar.get_avatar_url(message.uid, message.uname)
room = client_room_manager.get_room(client.tmp_room_id)
room = client_room_manager.get_room(client.room_key)
if room is None:
return
@@ -330,9 +419,9 @@ class LiveMsgHandler(blivedm.BaseHandler):
content_type = api.chat.ContentType.TEXT
content_type_params = None
text_emoticons = self._parse_text_emoticons(message)
need_translate = content_type != api.chat.ContentType.EMOTICON and self._need_translate(message.msg, room)
need_translate = (
content_type != api.chat.ContentType.EMOTICON and self._need_translate(message.msg, room, client)
)
if need_translate:
translation = services.translate.get_translation_from_cache(message.msg)
if translation is None:
@@ -360,30 +449,12 @@ class LiveMsgHandler(blivedm.BaseHandler):
translation=translation,
content_type=content_type,
content_type_params=content_type_params,
text_emoticons=text_emoticons,
))
if need_translate:
await self._translate_and_response(message.msg, room.room_id, msg_id)
await self._translate_and_response(message.msg, room.room_key, msg_id)
@staticmethod
def _parse_text_emoticons(message: blivedm.DanmakuMessage):
try:
extra = json.loads(message.mode_info['extra'])
# {"[dog]":{"emoticon_id":208,"emoji":"[dog]","descript":"[dog]","url":"http://i0.hdslb.com/bfs/live/4428c8
# 4e694fbf4e0ef6c06e958d9352c3582740.png","width":20,"height":20,"emoticon_unique":"emoji_208","count":1}}
emoticons = extra['emots']
if emoticons is None:
return []
res = [
(emoticon['descript'], emoticon['url'])
for emoticon in emoticons.values()
]
return res
except (json.JSONDecodeError, TypeError, KeyError):
return []
async def _on_gift(self, client: LiveClient, message: blivedm.GiftMessage):
def _on_gift(self, client: WebLiveClient, message: dm_web_models.GiftMessage):
avatar_url = services.avatar.process_avatar_url(message.face)
services.avatar.update_avatar_cache_if_expired(message.uid, avatar_url)
@@ -391,7 +462,7 @@ class LiveMsgHandler(blivedm.BaseHandler):
if message.coin_type != 'gold':
return
room = client_room_manager.get_room(client.tmp_room_id)
room = client_room_manager.get_room(client.room_key)
if room is None:
return
@@ -405,15 +476,15 @@ class LiveMsgHandler(blivedm.BaseHandler):
'num': message.num
})
async def _on_buy_guard(self, client: LiveClient, message: blivedm.GuardBuyMessage):
def _on_buy_guard(self, client: WebLiveClient, message: dm_web_models.GuardBuyMessage):
asyncio.create_task(self.__on_buy_guard(client, message))
@staticmethod
async def __on_buy_guard(client: LiveClient, message: blivedm.GuardBuyMessage):
async def __on_buy_guard(client: WebLiveClient, message: dm_web_models.GuardBuyMessage):
# 先异步调用再获取房间,因为返回时房间可能已经不存在了
avatar_url = await services.avatar.get_avatar_url(message.uid)
avatar_url = await services.avatar.get_avatar_url(message.uid, message.username)
room = client_room_manager.get_room(client.tmp_room_id)
room = client_room_manager.get_room(client.room_key)
if room is None:
return
@@ -425,15 +496,15 @@ class LiveMsgHandler(blivedm.BaseHandler):
'privilegeType': message.guard_level
})
async def _on_super_chat(self, client: LiveClient, message: blivedm.SuperChatMessage):
def _on_super_chat(self, client: WebLiveClient, message: dm_web_models.SuperChatMessage):
avatar_url = services.avatar.process_avatar_url(message.face)
services.avatar.update_avatar_cache_if_expired(message.uid, avatar_url)
room = client_room_manager.get_room(client.tmp_room_id)
room = client_room_manager.get_room(client.room_key)
if room is None:
return
need_translate = self._need_translate(message.message, room)
need_translate = self._need_translate(message.message, room, client)
if need_translate:
translation = services.translate.get_translation_from_cache(message.message)
if translation is None:
@@ -457,11 +528,11 @@ class LiveMsgHandler(blivedm.BaseHandler):
if need_translate:
asyncio.create_task(self._translate_and_response(
message.message, room.room_id, msg_id, services.translate.Priority.HIGH
message.message, room.room_key, msg_id, services.translate.Priority.HIGH
))
async def _on_super_chat_delete(self, client: LiveClient, message: blivedm.SuperChatDeleteMessage):
room = client_room_manager.get_room(client.tmp_room_id)
def _on_super_chat_delete(self, client: WebLiveClient, message: dm_web_models.SuperChatDeleteMessage):
room = client_room_manager.get_room(client.room_key)
if room is None:
return
@@ -470,22 +541,22 @@ class LiveMsgHandler(blivedm.BaseHandler):
})
@staticmethod
def _need_translate(text, room: ClientRoom):
def _need_translate(text, room: ClientRoom, client: LiveClientType):
cfg = config.get_config()
return (
cfg.enable_translate
and room.need_translate
and (not cfg.allow_translate_rooms or room.room_id in cfg.allow_translate_rooms)
and (not cfg.allow_translate_rooms or client.room_id in cfg.allow_translate_rooms)
and services.translate.need_translate(text)
)
@staticmethod
async def _translate_and_response(text, room_id, msg_id, priority=services.translate.Priority.NORMAL):
async def _translate_and_response(text, room_key: RoomKey, msg_id, priority=services.translate.Priority.NORMAL):
translation = await services.translate.translate(text, priority)
if translation is None:
return
room = client_room_manager.get_room(room_id)
room = client_room_manager.get_room(room_key)
if room is None:
return
@@ -497,3 +568,141 @@ class LiveMsgHandler(blivedm.BaseHandler):
translation
)
)
#
# 开放平台消息
#
def _on_open_live_danmaku(self, client: OpenLiveClient, message: dm_open_models.DanmakuMessage):
avatar_url = message.uface
services.avatar.update_avatar_cache_if_expired(message.uid, avatar_url)
room = client_room_manager.get_room(client.room_key)
if room is None:
return
if message.uid == client.room_owner_uid:
author_type = 3 # 主播
elif message.guard_level != 0: # 1总督2提督3舰长
author_type = 1 # 舰队
else:
author_type = 0
if message.dm_type == 1:
content_type = api.chat.ContentType.EMOTICON
content_type_params = api.chat.make_emoticon_params(message.emoji_img_url)
else:
content_type = api.chat.ContentType.TEXT
content_type_params = None
need_translate = (
content_type != api.chat.ContentType.EMOTICON and self._need_translate(message.msg, room, client)
)
if need_translate:
translation = services.translate.get_translation_from_cache(message.msg)
if translation is None:
# 没有缓存,需要后面异步翻译后通知
translation = ''
else:
need_translate = False
else:
translation = ''
room.send_cmd_data(api.chat.Command.ADD_TEXT, api.chat.make_text_message_data(
avatar_url=avatar_url,
timestamp=message.timestamp,
author_name=message.uname,
author_type=author_type,
content=message.msg,
privilege_type=message.guard_level,
medal_level=0 if not message.fans_medal_wearing_status else message.fans_medal_level,
id_=message.msg_id,
translation=translation,
content_type=content_type,
content_type_params=content_type_params,
))
if need_translate:
asyncio.create_task(self._translate_and_response(message.msg, room.room_key, message.msg_id))
def _on_open_live_gift(self, client: OpenLiveClient, message: dm_open_models.GiftMessage):
avatar_url = services.avatar.process_avatar_url(message.uface)
services.avatar.update_avatar_cache_if_expired(message.uid, avatar_url)
# 丢人
if not message.paid:
return
room = client_room_manager.get_room(client.room_key)
if room is None:
return
room.send_cmd_data(api.chat.Command.ADD_GIFT, {
'id': message.msg_id,
'avatarUrl': avatar_url,
'timestamp': message.timestamp,
'authorName': message.uname,
'totalCoin': message.price * message.gift_num,
'giftName': message.gift_name,
'num': message.gift_num
})
def _on_open_live_buy_guard(self, client: OpenLiveClient, message: dm_open_models.GuardBuyMessage):
avatar_url = message.user_info.uface
services.avatar.update_avatar_cache_if_expired(message.user_info.uid, avatar_url)
room = client_room_manager.get_room(client.room_key)
if room is None:
return
room.send_cmd_data(api.chat.Command.ADD_MEMBER, {
'id': message.msg_id,
'avatarUrl': avatar_url,
'timestamp': message.timestamp,
'authorName': message.user_info.uname,
'privilegeType': message.guard_level
})
def _on_open_live_super_chat(self, client: OpenLiveClient, message: dm_open_models.SuperChatMessage):
avatar_url = services.avatar.process_avatar_url(message.uface)
services.avatar.update_avatar_cache_if_expired(message.uid, avatar_url)
room = client_room_manager.get_room(client.room_key)
if room is None:
return
need_translate = self._need_translate(message.message, room, client)
if need_translate:
translation = services.translate.get_translation_from_cache(message.message)
if translation is None:
# 没有缓存,需要后面异步翻译后通知
translation = ''
else:
need_translate = False
else:
translation = ''
msg_id = str(message.message_id)
room.send_cmd_data(api.chat.Command.ADD_SUPER_CHAT, {
'id': msg_id,
'avatarUrl': avatar_url,
'timestamp': message.start_time,
'authorName': message.uname,
'price': message.rmb,
'content': message.message,
'translation': translation
})
if need_translate:
asyncio.create_task(self._translate_and_response(
message.message, room.room_key, msg_id, services.translate.Priority.HIGH
))
def _on_open_live_super_chat_delete(self, client: OpenLiveClient, message: dm_open_models.SuperChatDeleteMessage):
room = client_room_manager.get_room(client.room_key)
if room is None:
return
room.send_cmd_data(api.chat.Command.DEL_SUPER_CHAT, {
'ids': list(map(str, message.message_ids))
})

View File

@@ -56,7 +56,7 @@ def init():
_translate_cache = cachetools.LRUCache(cfg.translation_cache_size)
# 总队列长度会超过translate_max_queue_size不用这么严格
_task_queues = [asyncio.Queue(cfg.translate_max_queue_size) for _ in range(len(Priority))]
asyncio.get_event_loop().create_task(_do_init())
asyncio.get_running_loop().create_task(_do_init())
async def _do_init():
@@ -386,21 +386,16 @@ class TencentTranslateFree(TranslateProvider):
return True
async def _reinit_coroutine(self):
try:
while True:
logger.debug('TencentTranslateFree reinit')
start_time = datetime.datetime.now()
try:
await self._do_init()
except asyncio.CancelledError:
raise
except BaseException: # noqa
pass
cost_time = (datetime.datetime.now() - start_time).total_seconds()
while True:
logger.debug('TencentTranslateFree reinit')
start_time = datetime.datetime.now()
try:
await self._do_init()
except Exception: # noqa
pass
cost_time = (datetime.datetime.now() - start_time).total_seconds()
await asyncio.sleep(30 - cost_time)
except asyncio.CancelledError:
pass
await asyncio.sleep(30 - cost_time)
@property
def is_available(self):

View File

@@ -5,11 +5,11 @@ import aiohttp
import utils.request
VERSION = 'v1.7.0'
VERSION = 'v1.8.2'
def check_update():
asyncio.get_event_loop().create_task(_do_check_update())
asyncio.get_running_loop().create_task(_do_check_update())
async def _do_check_update():

View File

@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
import asyncio
from typing import *
import aiohttp
@@ -16,9 +15,10 @@ http_session: Optional[aiohttp.ClientSession] = None
def init():
# ClientSession要在异步函数中创建
async def do_init():
global http_session
http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
global http_session
http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
asyncio.get_event_loop().run_until_complete(do_init())
async def shut_down():
if http_session is not None:
await http_session.close()