diff --git a/.dockerignore b/.dockerignore index ab6ea41b..a9a6e8be 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,5 +17,5 @@ README.md # runtime data data/* -!data/config.ini +!data/config.example.ini log/* diff --git a/.gitignore b/.gitignore index a34edba4..b3fc5400 100644 --- a/.gitignore +++ b/.gitignore @@ -105,5 +105,6 @@ venv.bak/ .idea/ -data/database.db -*.log* +data/* +!data/config.example.ini +log/* diff --git a/Dockerfile b/Dockerfile index a8758ae4..98aa96a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # 运行时 -FROM python:3.6.8-slim-stretch +FROM python:3.7.10-slim-stretch RUN mv /etc/apt/sources.list /etc/apt/sources.list.bak \ && echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian/ stretch main contrib non-free">>/etc/apt/sources.list \ && echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian/ stretch-updates main contrib non-free">>/etc/apt/sources.list \ diff --git a/api/chat.py b/api/chat.py index 9a282f42..f34b734a 100644 --- a/api/chat.py +++ b/api/chat.py @@ -32,7 +32,7 @@ class Command(enum.IntEnum): UPDATE_TRANSLATION = 7 -_http_session = aiohttp.ClientSession() +_http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) room_manager: Optional['RoomManager'] = None @@ -43,6 +43,8 @@ def init(): class Room(blivedm.BLiveClient): + HEARTBEAT_INTERVAL = 10 + # 重新定义parse_XXX是为了减少对字段名的依赖,防止B站改字段名 def __parse_danmaku(self, command): info = command['info'] @@ -97,7 +99,7 @@ def __parse_super_chat(self, command): } def __init__(self, room_id): - super().__init__(room_id, session=_http_session, heartbeat_interval=10) + super().__init__(room_id, session=_http_session, heartbeat_interval=self.HEARTBEAT_INTERVAL) self.clients: List['ChatHandler'] = [] self.auto_translate_count = 0 @@ -365,34 +367,68 @@ def _del_room(self, room_id): # noinspection PyAbstractClass class ChatHandler(tornado.websocket.WebSocketHandler): + HEARTBEAT_INTERVAL = 10 + RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5 + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._close_on_timeout_future = None + self._heartbeat_timer_handle = None + self._receive_timeout_timer_handle = None + self.room_id = None self.auto_translate = False def open(self): logger.info('Websocket connected %s', self.request.remote_ip) - self._close_on_timeout_future = asyncio.ensure_future(self._close_on_timeout()) + self._heartbeat_timer_handle = asyncio.get_event_loop().call_later( + self.HEARTBEAT_INTERVAL, self._on_send_heartbeat + ) + self._refresh_receive_timeout_timer() - async def _close_on_timeout(self): - try: - # 超过一定时间还没加入房间则断开 - await asyncio.sleep(10) - logger.warning('Client %s joining room timed out', self.request.remote_ip) - self.close() - except (asyncio.CancelledError, tornado.websocket.WebSocketClosedError): - pass + def _on_send_heartbeat(self): + self.send_message(Command.HEARTBEAT, {}) + self._heartbeat_timer_handle = asyncio.get_event_loop().call_later( + self.HEARTBEAT_INTERVAL, self._on_send_heartbeat + ) + + def _refresh_receive_timeout_timer(self): + if self._receive_timeout_timer_handle is not None: + self._receive_timeout_timer_handle.cancel() + self._receive_timeout_timer_handle = asyncio.get_event_loop().call_later( + self.RECEIVE_TIMEOUT, self._on_receive_timeout + ) + + def _on_receive_timeout(self): + logger.warning('Client %s timed out', self.request.remote_ip) + self._receive_timeout_timer_handle = None + self.close() + + def on_close(self): + logger.info('Websocket disconnected %s room: %s', self.request.remote_ip, str(self.room_id)) + if self.has_joined_room: + room_manager.del_client(self.room_id, self) + if self._heartbeat_timer_handle is not None: + self._heartbeat_timer_handle.cancel() + self._heartbeat_timer_handle = None + if self._receive_timeout_timer_handle is not None: + self._receive_timeout_timer_handle.cancel() + self._receive_timeout_timer_handle = None def on_message(self, message): try: + # 超时没有加入房间也断开 + if self.has_joined_room: + self._refresh_receive_timeout_timer() + body = json.loads(message) cmd = body['cmd'] if cmd == Command.HEARTBEAT: - return + pass 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 is joining room %d', self.request.remote_ip, self.room_id) try: @@ -402,21 +438,11 @@ def on_message(self, message): pass asyncio.ensure_future(room_manager.add_client(self.room_id, self)) - self._close_on_timeout_future.cancel() - self._close_on_timeout_future = None else: logger.warning('Unknown cmd, client: %s, cmd: %d, body: %s', self.request.remote_ip, cmd, body) except Exception: logger.exception('on_message error, client: %s, message: %s', self.request.remote_ip, message) - def on_close(self): - logger.info('Websocket disconnected %s room: %s', self.request.remote_ip, str(self.room_id)) - if self.has_joined_room: - room_manager.del_client(self.room_id, self) - if self._close_on_timeout_future is not None: - self._close_on_timeout_future.cancel() - self._close_on_timeout_future = None - # 跨域测试用 def check_origin(self, origin): if self.application.settings['debug']: @@ -432,7 +458,7 @@ def send_message(self, cmd, data): try: self.write_message(body) except tornado.websocket.WebSocketClosedError: - self.on_close() + self.close() async def on_join_room(self): if self.application.settings['debug']: @@ -550,7 +576,7 @@ async def _get_room_info(room_id): res.status, res.reason) return room_id, 0 data = await res.json() - except aiohttp.ClientConnectionError: + except (aiohttp.ClientConnectionError, asyncio.TimeoutError): logger.exception('room %d _get_room_info failed', room_id) return room_id, 0 @@ -574,7 +600,7 @@ async def _get_server_host_list(cls, _room_id): # res.status, res.reason) # return cls._host_server_list_cache # data = await res.json() - # except aiohttp.ClientConnectionError: + # except (aiohttp.ClientConnectionError, asyncio.TimeoutError): # logger.exception('room %d _get_server_host_list failed', room_id) # return cls._host_server_list_cache # diff --git a/blivedm b/blivedm index 8d8cc8c2..4669b2c1 160000 --- a/blivedm +++ b/blivedm @@ -1 +1 @@ -Subproject commit 8d8cc8c2706d62bbfa74cbc36f536b9717fe8f36 +Subproject commit 4669b2c1c9a1654db340d02ff16c9f88be661d9f diff --git a/config.py b/config.py index d1c989ca..26535527 100644 --- a/config.py +++ b/config.py @@ -7,7 +7,10 @@ logger = logging.getLogger(__name__) -CONFIG_PATH = os.path.join('data', 'config.ini') +CONFIG_PATH_LIST = [ + os.path.join('data', 'config.ini'), + os.path.join('data', 'config.example.ini') +] _config: Optional['AppConfig'] = None @@ -21,8 +24,16 @@ def init(): def reload(): + config_path = '' + for path in CONFIG_PATH_LIST: + if os.path.exists(path): + config_path = path + break + if config_path == '': + return False + config = AppConfig() - if not config.load(CONFIG_PATH): + if not config.load(config_path): return False global _config _config = config @@ -36,31 +47,86 @@ def get_config(): class AppConfig: def __init__(self): self.database_url = 'sqlite:///data/database.db' - self.enable_translate = True - self.allow_translate_rooms = {} self.tornado_xheaders = False self.loader_url = '' + self.fetch_avatar_interval = 3.5 + self.fetch_avatar_max_queue_size = 2 + self.avatar_cache_size = 50000 + + self.enable_translate = True + self.allow_translate_rooms = set() + self.translation_cache_size = 50000 + self.translator_configs = [] + def load(self, path): try: config = configparser.ConfigParser() config.read(path, 'utf-8') - app_section = config['app'] - self.database_url = app_section['database_url'] - self.enable_translate = app_section.getboolean('enable_translate') + self._load_app_config(config) + self._load_translator_configs(config) + except Exception: + logger.exception('Failed to load config:') + return False + return True - allow_translate_rooms = app_section['allow_translate_rooms'] - if allow_translate_rooms == '': - self.allow_translate_rooms = {} + def _load_app_config(self, config): + app_section = config['app'] + self.database_url = app_section['database_url'] + self.tornado_xheaders = app_section.getboolean('tornado_xheaders') + self.loader_url = app_section['loader_url'] + + self.fetch_avatar_interval = app_section.getfloat('fetch_avatar_interval') + self.fetch_avatar_max_queue_size = app_section.getint('fetch_avatar_max_queue_size') + self.avatar_cache_size = app_section.getint('avatar_cache_size') + + self.enable_translate = app_section.getboolean('enable_translate') + self.allow_translate_rooms = _str_to_list(app_section['allow_translate_rooms'], int, set) + self.translation_cache_size = app_section.getint('translation_cache_size') + + def _load_translator_configs(self, config): + app_section = config['app'] + section_names = _str_to_list(app_section['translator_configs']) + translator_configs = [] + for section_name in section_names: + section = config[section_name] + type_ = section['type'] + + translator_config = { + 'type': type_, + 'query_interval': section.getfloat('query_interval'), + 'max_queue_size': section.getint('max_queue_size') + } + if type_ == 'TencentTranslateFree': + translator_config['source_language'] = section['source_language'] + translator_config['target_language'] = section['target_language'] + elif type_ == 'BilibiliTranslateFree': + pass + elif type_ == 'TencentTranslate': + translator_config['source_language'] = section['source_language'] + translator_config['target_language'] = section['target_language'] + translator_config['secret_id'] = section['secret_id'] + translator_config['secret_key'] = section['secret_key'] + translator_config['region'] = section['region'] + elif type_ == 'BaiduTranslate': + translator_config['source_language'] = section['source_language'] + translator_config['target_language'] = section['target_language'] + translator_config['app_id'] = section['app_id'] + translator_config['secret'] = section['secret'] else: - allow_translate_rooms = allow_translate_rooms.split(',') - self.allow_translate_rooms = set(map(lambda id_: int(id_.strip()), allow_translate_rooms)) + raise ValueError(f'Invalid translator type: {type_}') - self.tornado_xheaders = app_section.getboolean('tornado_xheaders') - self.loader_url = app_section['loader_url'] + translator_configs.append(translator_config) + self.translator_configs = translator_configs - except (KeyError, ValueError): - logger.exception('Failed to load config:') - return False - return True + +def _str_to_list(value, item_type: Type=str, container_type: Type=list): + value = value.strip() + if value == '': + return container_type() + items = value.split(',') + items = map(lambda item: item.strip(), items) + if item_type is not str: + items = map(lambda item: item_type(item), items) + return container_type(items) diff --git a/data/config.example.ini b/data/config.example.ini new file mode 100644 index 00000000..3d2a392e --- /dev/null +++ b/data/config.example.ini @@ -0,0 +1,142 @@ +# 如果要修改配置,可以复制此文件并重命名为“config.ini”再修改 +# If you want to modify the configuration, copy this file and rename it to "config.ini" and edit + +[app] +# 数据库配置,见https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls +# See https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls +database_url = sqlite:///data/database.db + +# 如果使用了nginx之类的反向代理服务器,设置为true +# Set to true if you are using a reverse proxy server such as nginx +tornado_xheaders = false + +# 加载器URL,本地使用时加载器可以让你先运行OBS再运行blivechat。如果为空,不使用加载器 +# **自建服务器时强烈建议不使用加载器**,否则可能因为混合HTTP和HTTPS等原因加载不出来 +# Use a loader so that you can run OBS before blivechat. If empty, no loader is used +loader_url = https://xfgryujk.sinacloud.net/blivechat/loader.html + + +# 获取头像间隔时间(秒)。如果小于3秒有很大概率被服务器拉黑 +# Interval between fetching avatar (s). At least 3 seconds is recommended +fetch_avatar_interval = 3.5 + +# 获取头像最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间 +# Maximum queue length for fetching avatar +fetch_avatar_max_queue_size = 2 + +# 头像缓存数量 +# Number of avatar caches +avatar_cache_size = 50000 + + +# 允许自动翻译到日语 +# Enable auto translate to Japanese +enable_translate = true + +# 允许翻译的房间ID,以逗号分隔。如果为空,允许所有房间 +# Comma separated room IDs in which translation are not allowed. If empty, all are allowed +# Example: allow_translate_rooms = 4895312,22347054,21693691 +allow_translate_rooms = + +# 翻译缓存数量 +# Number of translation caches +translation_cache_size = 50000 + + +# ------------------------------------------------------------------------------------------------- +# 以下是给字幕组看的,实在懒得翻译了_(:з」∠)_。如果你不了解以下参数的意思,使用默认值就好 +# **The following is for translation team. Leave it default if you don't know its meaning** +# ------------------------------------------------------------------------------------------------- + +# 翻译器配置,索引到下面的配置节。可以以逗号分隔配置多个翻译器,翻译时会自动负载均衡 +# 配置多个翻译器可以增加额度、增加QPS、容灾 +# 不同配置可以使用同一个类型,但要使用不同的账号,否则还是会遇到额度、调用频率限制 +translator_configs = tencent_translate_free,bilibili_translate_free + + +[tencent_translate_free] +# 类型:腾讯翻译白嫖版。使用了网页版的接口,**将来可能失效** +type = TencentTranslateFree + +# 请求间隔时间(秒),等于 1 / QPS。目前没有遇到此接口有调用频率限制,10QPS应该够用了 +query_interval = 0.1 +# 最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间 +max_queue_size = 100 + +# 自动:auto;中文:zh;日语:jp;英语:en;韩语:kr +# 完整语言列表见文档:https://cloud.tencent.com/document/product/551/15619 +# 源语言 +source_language = zh +# 目标语言 +target_language = jp + + +[bilibili_translate_free] +# 类型:B站翻译白嫖版。使用了B站直播网页的接口,**将来可能失效**。目前B站翻译后端是百度翻译 +type = BilibiliTranslateFree + +# 请求间隔时间(秒),等于 1 / QPS。目前此接口频率限制是3秒一次 +query_interval = 3.1 +# 最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间 +max_queue_size = 3 + + +[tencent_translate] +# 文档:https://cloud.tencent.com/product/tmt +# 定价:https://cloud.tencent.com/document/product/551/35017 +# * 文本翻译的每月免费额度为5百万字符 +# * 文本翻译当月需付费字符数小于100百万字符(1亿字符)时,刊例价为58元/每百万字符 +# * 文本翻译当月需付费字符数大于等于100百万字符(1亿字符)时,刊例价为50元/每百万字符 +# 限制:https://cloud.tencent.com/document/product/551/32572 +# * 文本翻译最高QPS为5 + +# 类型:腾讯翻译 +type = TencentTranslate + +# 请求间隔时间(秒),等于 1 / QPS。理论上最高QPS为5,实际测试是3 +query_interval = 0.333 +# 最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间 +max_queue_size = 30 + +# 自动:auto;中文:zh;日语:jp;英语:en;韩语:kr +# 完整语言列表见文档:https://cloud.tencent.com/document/product/551/15619 +# 源语言 +source_language = zh +# 目标语言 +target_language = jp + +# 腾讯云API密钥 +secret_id = +secret_key = + +# 腾讯云地域参数,用来标识希望操作哪个地域的数据 +# 北京:ap-beijing;上海:ap-shanghai;香港:ap-hongkong;首尔:ap-seoul +# 完整地域列表见文档:https://cloud.tencent.com/document/api/551/15615#.E5.9C.B0.E5.9F.9F.E5.88.97.E8.A1.A8 +region = ap-shanghai + + +[baidu_translate] +# 文档:https://fanyi-api.baidu.com/ +# 定价:https://fanyi-api.baidu.com/product/112 +# * 标准版完全免费,不限使用字符量(QPS=1) +# * 高级版每月前200万字符免费,超出后仅收取超出部分费用(QPS=10),49元/百万字符 +# * 尊享版每月前200万字符免费,超出后仅收取超出部分费用(QPS=100),49元/百万字符 + +# 类型:百度翻译 +type = BaiduTranslate + +# 请求间隔时间(秒),等于 1 / QPS +query_interval = 1.5 +# 最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间 +max_queue_size = 9 + +# 自动:auto;中文:zh;日语:jp;英语:en;韩语:kor +# 完整语言列表见文档:https://fanyi-api.baidu.com/doc/21 +# 源语言 +source_language = zh +# 目标语言 +target_language = jp + +# 百度翻译开放平台应用ID和密钥 +app_id = +secret = diff --git a/data/config.ini b/data/config.ini deleted file mode 100644 index b21432bc..00000000 --- a/data/config.ini +++ /dev/null @@ -1,22 +0,0 @@ -[app] -# 数据库配置,见https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls -# See https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls -database_url = sqlite:///data/database.db - -# 允许自动翻译到日语 -# Enable auto translate to Japanese -enable_translate = true - -# 允许翻译的房间ID,以逗号分隔。如果为空,允许所有房间 -# Comma separated room IDs in which translation are not allowed. If empty, all are allowed -# Example: allow_translate_rooms = 4895312,22347054,21693691 -allow_translate_rooms = - -# 如果使用了nginx之类的反向代理服务器,设置为true -# Set to true if you are using a reverse proxy server such as nginx -tornado_xheaders = false - -# 加载器URL,本地使用时加载器可以让你先运行OBS再运行blivechat。如果为空,不使用加载器 -# **自建服务器时强烈建议不使用加载器**,否则可能因为混合HTTP和HTTPS等原因加载不出来 -# Use a loader so that you can run OBS before blivechat. If empty, no loader is used -loader_url = https://xfgryujk.sinacloud.net/blivechat/loader.html diff --git a/frontend/.gitignore b/frontend/.gitignore index a0dddc6f..da9593d5 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -19,3 +19,6 @@ yarn-error.log* *.njsproj *.sln *.sw? + + +package-lock.json diff --git a/frontend/babel.config.js b/frontend/babel.config.js index 42ecc885..3de35ba6 100644 --- a/frontend/babel.config.js +++ b/frontend/babel.config.js @@ -1,6 +1,6 @@ module.exports = { presets: [ - '@vue/app' + '@vue/cli-plugin-babel/preset' ], plugins: [ [ diff --git a/frontend/package.json b/frontend/package.json index d5ad3f59..3bf35a7e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,8 +8,8 @@ "lint": "vue-cli-service lint" }, "dependencies": { - "axios": "^0.19.0", - "core-js": "^2.6.5", + "axios": "^0.21.1", + "core-js": "^3.6.5", "downloadjs": "^1.4.7", "element-ui": "^2.9.1", "lodash": "^4.17.19", @@ -19,13 +19,13 @@ "vue-router": "^3.0.6" }, "devDependencies": { - "@vue/cli-plugin-babel": "^3.7.0", - "@vue/cli-plugin-eslint": "^3.7.0", - "@vue/cli-service": "^4.2.2", - "babel-eslint": "^10.0.1", + "@vue/cli-plugin-babel": "^4.5.12", + "@vue/cli-plugin-eslint": "^4.5.12", + "@vue/cli-service": "~4.5.12", + "babel-eslint": "^10.1.0", "babel-plugin-component": "^1.1.1", - "eslint": "^5.16.0", - "eslint-plugin-vue": "^5.0.0", + "eslint": "^6.7.2", + "eslint-plugin-vue": "^6.2.2", "vue-template-compiler": "^2.5.21" }, "eslintConfig": { diff --git a/frontend/src/api/chat/ChatClientDirect.js b/frontend/src/api/chat/ChatClientDirect.js index f83174f4..728bee9d 100644 --- a/frontend/src/api/chat/ChatClientDirect.js +++ b/frontend/src/api/chat/ChatClientDirect.js @@ -6,8 +6,8 @@ import * as avatar from './avatar' const HEADER_SIZE = 16 -// const WS_BODY_PROTOCOL_VERSION_NORMAL = 0 -// const WS_BODY_PROTOCOL_VERSION_INT = 1 // 用于心跳包 +// const WS_BODY_PROTOCOL_VERSION_INFLATE = 0 +// const WS_BODY_PROTOCOL_VERSION_NORMAL = 1 const WS_BODY_PROTOCOL_VERSION_DEFLATE = 2 // const OP_HANDSHAKE = 0 @@ -32,6 +32,9 @@ const OP_AUTH_REPLY = 8 // const MinBusinessOp = 1000 // const MaxBusinessOp = 10000 +const HEARTBEAT_INTERVAL = 10 * 1000 +const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5 * 1000 + let textEncoder = new TextEncoder() let textDecoder = new TextDecoder() @@ -55,6 +58,7 @@ export default class ChatClientDirect { this.retryCount = 0 this.isDestroying = false this.heartbeatTimerId = null + this.receiveTimeoutTimerId = null } async start () { @@ -120,15 +124,33 @@ export default class ChatClientDirect { this.websocket.onopen = this.onWsOpen.bind(this) this.websocket.onclose = this.onWsClose.bind(this) this.websocket.onmessage = this.onWsMessage.bind(this) - this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), 10 * 1000) + } + + onWsOpen () { + this.sendAuth() + this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), HEARTBEAT_INTERVAL) + this.refreshReceiveTimeoutTimer() } sendHeartbeat () { this.websocket.send(this.makePacket({}, OP_HEARTBEAT)) } - onWsOpen () { - this.sendAuth() + refreshReceiveTimeoutTimer() { + if (this.receiveTimeoutTimerId) { + window.clearTimeout(this.receiveTimeoutTimerId) + } + this.receiveTimeoutTimerId = window.setTimeout(this.onReceiveTimeout.bind(this), RECEIVE_TIMEOUT) + } + + onReceiveTimeout() { + window.console.warn('接收消息超时') + this.receiveTimeoutTimerId = null + + // 直接丢弃阻塞的websocket,不等onclose回调了 + this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null + this.websocket.close() + this.onWsClose() } onWsClose () { @@ -137,19 +159,26 @@ export default class ChatClientDirect { window.clearInterval(this.heartbeatTimerId) this.heartbeatTimerId = null } + if (this.receiveTimeoutTimerId) { + window.clearTimeout(this.receiveTimeoutTimerId) + this.receiveTimeoutTimerId = null + } + if (this.isDestroying) { return } - window.console.log(`掉线重连中${++this.retryCount}`) + window.console.warn(`掉线重连中${++this.retryCount}`) window.setTimeout(this.wsConnect.bind(this), 1000) } onWsMessage (event) { + this.refreshReceiveTimeoutTimer() this.retryCount = 0 if (!(event.data instanceof ArrayBuffer)) { window.console.warn('未知的websocket消息:', event.data) return } + let data = new Uint8Array(event.data) this.handlerMessage(data) } diff --git a/frontend/src/api/chat/ChatClientRelay.js b/frontend/src/api/chat/ChatClientRelay.js index b16c7267..2ce3c9a6 100644 --- a/frontend/src/api/chat/ChatClientRelay.js +++ b/frontend/src/api/chat/ChatClientRelay.js @@ -7,6 +7,9 @@ const COMMAND_ADD_SUPER_CHAT = 5 const COMMAND_DEL_SUPER_CHAT = 6 const COMMAND_UPDATE_TRANSLATION = 7 +const HEARTBEAT_INTERVAL = 10 * 1000 +const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5 * 1000 + export default class ChatClientRelay { constructor (roomId, autoTranslate) { this.roomId = roomId @@ -23,6 +26,7 @@ export default class ChatClientRelay { this.retryCount = 0 this.isDestroying = false this.heartbeatTimerId = null + this.receiveTimeoutTimerId = null } start () { @@ -48,13 +52,6 @@ export default class ChatClientRelay { this.websocket.onopen = this.onWsOpen.bind(this) this.websocket.onclose = this.onWsClose.bind(this) this.websocket.onmessage = this.onWsMessage.bind(this) - this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), 10 * 1000) - } - - sendHeartbeat () { - this.websocket.send(JSON.stringify({ - cmd: COMMAND_HEARTBEAT - })) } onWsOpen () { @@ -68,6 +65,31 @@ export default class ChatClientRelay { } } })) + this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), HEARTBEAT_INTERVAL) + this.refreshReceiveTimeoutTimer() + } + + sendHeartbeat () { + this.websocket.send(JSON.stringify({ + cmd: COMMAND_HEARTBEAT + })) + } + + refreshReceiveTimeoutTimer() { + if (this.receiveTimeoutTimerId) { + window.clearTimeout(this.receiveTimeoutTimerId) + } + this.receiveTimeoutTimerId = window.setTimeout(this.onReceiveTimeout.bind(this), RECEIVE_TIMEOUT) + } + + onReceiveTimeout() { + window.console.warn('接收消息超时') + this.receiveTimeoutTimerId = null + + // 直接丢弃阻塞的websocket,不等onclose回调了 + this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null + this.websocket.close() + this.onWsClose() } onWsClose () { @@ -76,16 +98,26 @@ export default class ChatClientRelay { window.clearInterval(this.heartbeatTimerId) this.heartbeatTimerId = null } + if (this.receiveTimeoutTimerId) { + window.clearTimeout(this.receiveTimeoutTimerId) + this.receiveTimeoutTimerId = null + } + if (this.isDestroying) { return } - window.console.log(`掉线重连中${++this.retryCount}`) + window.console.warn(`掉线重连中${++this.retryCount}`) window.setTimeout(this.wsConnect.bind(this), 1000) } onWsMessage (event) { + this.refreshReceiveTimeoutTimer() + let {cmd, data} = JSON.parse(event.data) switch (cmd) { + case COMMAND_HEARTBEAT: { + break + } case COMMAND_ADD_TEXT: { if (!this.onAddText) { break diff --git a/frontend/src/api/chat/ChatClientTest.js b/frontend/src/api/chat/ChatClientTest.js new file mode 100644 index 00000000..2da93edd --- /dev/null +++ b/frontend/src/api/chat/ChatClientTest.js @@ -0,0 +1,211 @@ +import {getUuid4Hex} from '@/utils' +import * as constants from '@/components/ChatRenderer/constants' +import * as avatar from './avatar' + +const NAMES = [ + 'xfgryujk', 'Simon', 'Il Harper', 'Kinori', 'shugen', 'yuyuyzl', '3Shain', '光羊', '黑炎', 'Misty', '孤梦星影', + 'ジョナサン・ジョースター', 'ジョセフ・ジョースター', 'ディオ・ブランドー', '空條承太郎', '博丽灵梦', '雾雨魔理沙', + 'Rick Astley' +] + +const CONTENTS = [ + '草', 'kksk', '8888888888', '888888888888888888888888888888', '老板大气,老板身体健康', + 'The quick brown fox jumps over the lazy dog', "I can eat glass, it doesn't hurt me", + '我不做人了,JOJO', '無駄無駄無駄無駄無駄無駄無駄無駄', '欧啦欧啦欧啦欧啦欧啦欧啦欧啦欧啦', '逃げるんだよォ!', + '嚯,朝我走过来了吗,没有选择逃跑而是主动接近我么', '不要停下来啊', '已经没有什么好怕的了', + 'I am the bone of my sword. Steel is my body, and fire is my blood.', '言いたいことがあるんだよ!', + '我忘不掉夏小姐了。如果不是知道了夏小姐,说不定我已经对这个世界没有留恋了', '迷えば、敗れる', + 'Farewell, ashen one. May the flame guide thee', '竜神の剣を喰らえ!', '竜が我が敌を喰らう!', + '有一说一,这件事大家懂的都懂,不懂的,说了你也不明白,不如不说', '让我看看', '我柜子动了,我不玩了' +] + +const AUTHOR_TYPES = [ + {weight: 10, value: constants.AUTHRO_TYPE_NORMAL}, + {weight: 5, value: constants.AUTHRO_TYPE_MEMBER}, + {weight: 2, value: constants.AUTHRO_TYPE_ADMIN}, + {weight: 1, value: constants.AUTHRO_TYPE_OWNER} +] + +function randGuardInfo () { + let authorType = randomChoose(AUTHOR_TYPES) + let privilegeType + if (authorType === constants.AUTHRO_TYPE_MEMBER || authorType === constants.AUTHRO_TYPE_ADMIN) { + privilegeType = randInt(1, 3) + } else { + privilegeType = 0 + } + return {authorType, privilegeType} +} + +const GIFT_INFO_LIST = [ + {giftName: 'B坷垃', totalCoin: 9900}, + {giftName: '礼花', totalCoin: 28000}, + {giftName: '花式夸夸', totalCoin: 39000}, + {giftName: '天空之翼', totalCoin: 100000}, + {giftName: '摩天大楼', totalCoin: 450000}, + {giftName: '小电视飞船', totalCoin: 1245000} +] + +const SC_PRICES = [ + 30, 50, 100, 200, 500, 1000 +] + +const MESSAGE_GENERATORS = [ + // 文字 + { + weight: 20, + value() { + return { + type: constants.MESSAGE_TYPE_TEXT, + message: { + ...randGuardInfo(), + avatarUrl: avatar.DEFAULT_AVATAR_URL, + timestamp: new Date().getTime() / 1000, + authorName: randomChoose(NAMES), + content: randomChoose(CONTENTS), + isGiftDanmaku: randInt(1, 10) <= 1, + authorLevel: randInt(0, 60), + isNewbie: randInt(1, 10) <= 9, + isMobileVerified: randInt(1, 10) <= 9, + medalLevel: randInt(0, 40), + id: getUuid4Hex(), + translation: '' + } + } + } + }, + // 礼物 + { + weight: 1, + value() { + return { + type: constants.MESSAGE_TYPE_GIFT, + message: { + ...randomChoose(GIFT_INFO_LIST), + id: getUuid4Hex(), + avatarUrl: avatar.DEFAULT_AVATAR_URL, + timestamp: new Date().getTime() / 1000, + authorName: randomChoose(NAMES), + num: 1 + } + } + } + }, + // SC + { + weight: 3, + value() { + return { + type: constants.MESSAGE_TYPE_SUPER_CHAT, + message: { + id: getUuid4Hex(), + avatarUrl: avatar.DEFAULT_AVATAR_URL, + timestamp: new Date().getTime() / 1000, + authorName: randomChoose(NAMES), + price: randomChoose(SC_PRICES), + content: randomChoose(CONTENTS), + translation: '' + } + } + } + }, + // 新舰长 + { + weight: 1, + value() { + return { + type: constants.MESSAGE_TYPE_MEMBER, + message: { + id: getUuid4Hex(), + avatarUrl: avatar.DEFAULT_AVATAR_URL, + timestamp: new Date().getTime() / 1000, + authorName: randomChoose(NAMES), + privilegeType: randInt(1, 3) + } + } + } + } +] + +function randomChoose (nodes) { + if (nodes.length === 0) { + return null + } + for (let node of nodes) { + if (node.weight === undefined || node.value === undefined) { + return nodes[randInt(0, nodes.length - 1)] + } + } + + let totalWeight = 0 + for (let node of nodes) { + totalWeight += node.weight + } + let remainWeight = randInt(1, totalWeight) + for (let node of nodes) { + remainWeight -= node.weight + if (remainWeight > 0) { + continue + } + if (node.value instanceof Array) { + return randomChoose(node.value) + } + return node.value + } + return null +} + +function randInt (min, max) { + return Math.floor(min + (max - min + 1) * Math.random()) +} + +export default class ChatClientTest { + constructor () { + this.minSleepTime = 800 + this.maxSleepTime = 1200 + + this.onAddText = null + this.onAddGift = null + this.onAddMember = null + this.onAddSuperChat = null + this.onDelSuperChat = null + this.onUpdateTranslation = null + + this.timerId = null + } + + start () { + this.refreshTimer() + } + + stop () { + if (this.timerId) { + window.clearTimeout(this.timerId) + this.timerId = null + } + } + + refreshTimer () { + this.timerId = window.setTimeout(this.onTimeout.bind(this), randInt(this.minSleepTime, this.maxSleepTime)) + } + + onTimeout () { + this.refreshTimer() + + let {type, message} = randomChoose(MESSAGE_GENERATORS)() + switch (type) { + case constants.MESSAGE_TYPE_TEXT: + this.onAddText(message) + break + case constants.MESSAGE_TYPE_GIFT: + this.onAddGift(message) + break + case constants.MESSAGE_TYPE_MEMBER: + this.onAddMember(message) + break + case constants.MESSAGE_TYPE_SUPER_CHAT: + this.onAddSuperChat(message) + break + } + } +} diff --git a/frontend/src/api/chatConfig.js b/frontend/src/api/chatConfig.js index 5afaeae7..c315f307 100644 --- a/frontend/src/api/chatConfig.js +++ b/frontend/src/api/chatConfig.js @@ -11,8 +11,8 @@ export const DEFAULT_CONFIG = { blockGiftDanmaku: true, blockLevel: 0, - blockNewbie: true, - blockNotMobileVerified: true, + blockNewbie: false, + blockNotMobileVerified: false, blockKeywords: '', blockUsers: '', blockMedalLevel: 0, @@ -28,8 +28,9 @@ export function setLocalConfig (config) { } export function getLocalConfig () { - if (!window.localStorage.config) { - return DEFAULT_CONFIG + try { + return mergeConfig(JSON.parse(window.localStorage.config), DEFAULT_CONFIG) + } catch { + return {...DEFAULT_CONFIG} } - return mergeConfig(JSON.parse(window.localStorage.config), DEFAULT_CONFIG) } diff --git a/frontend/src/components/ChatRenderer/Ticker.vue b/frontend/src/components/ChatRenderer/Ticker.vue index a54cbb1b..e5f63337 100644 --- a/frontend/src/components/ChatRenderer/Ticker.vue +++ b/frontend/src/components/ChatRenderer/Ticker.vue @@ -1,7 +1,9 @@