blivechat/api/chat.py

404 lines
13 KiB
Python
Raw Normal View History

2019-05-22 01:11:23 +08:00
# -*- coding: utf-8 -*-
2019-05-22 14:10:27 +08:00
import asyncio
2019-05-22 01:11:23 +08:00
import enum
import json
2019-05-22 14:10:27 +08:00
import logging
2020-02-05 17:28:10 +08:00
import random
2019-05-22 19:38:36 +08:00
import time
2020-02-06 17:39:56 +08:00
import uuid
from typing import *
2019-05-22 01:11:23 +08:00
2019-05-22 15:42:45 +08:00
import aiohttp
2019-05-22 01:11:23 +08:00
import tornado.websocket
2020-09-13 21:24:20 +08:00
import api.base
2023-09-03 22:53:15 +08:00
import blivedm.blivedm.clients.web as dm_web_cli
2020-02-06 17:39:56 +08:00
import config
2022-02-15 00:18:46 +08:00
import services.avatar
import services.chat
import services.translate
import utils.request
2019-05-22 01:11:23 +08:00
2019-05-22 14:10:27 +08:00
logger = logging.getLogger(__name__)
2019-05-22 01:11:23 +08:00
class Command(enum.IntEnum):
2019-09-23 22:22:27 +08:00
HEARTBEAT = 0
JOIN_ROOM = 1
ADD_TEXT = 2
ADD_GIFT = 3
ADD_MEMBER = 4
ADD_SUPER_CHAT = 5
DEL_SUPER_CHAT = 6
2020-02-06 17:39:56 +08:00
UPDATE_TRANSLATION = 7
FATAL_ERROR = 8
2019-05-22 01:11:23 +08:00
2021-12-26 23:34:45 +08:00
class ContentType(enum.IntEnum):
TEXT = 0
EMOTICON = 1
class FatalErrorType(enum.IntEnum):
AUTH_CODE_ERROR = 1
def make_message_body(cmd, data):
return json.dumps(
{
'cmd': cmd,
'data': data
}
).encode('utf-8')
def make_text_message_data(
avatar_url: str = services.avatar.DEFAULT_AVATAR_URL,
timestamp: int = None,
author_name: str = '',
author_type: int = 0,
content: str = '',
privilege_type: int = 0,
is_gift_danmaku: bool = False,
author_level: int = 1,
is_newbie: bool = False,
is_mobile_verified: bool = True,
medal_level: int = 0,
id_: str = None,
translation: str = '',
content_type: int = ContentType.TEXT,
content_type_params: list = None,
uid: int = 0
):
# 为了节省带宽用list而不是dict
return [
# 0: avatarUrl
avatar_url,
# 1: timestamp
timestamp if timestamp is not None else int(time.time()),
# 2: authorName
author_name,
# 3: authorType
author_type,
# 4: content
content,
# 5: privilegeType
privilege_type,
# 6: isGiftDanmaku
1 if is_gift_danmaku else 0,
# 7: authorLevel
author_level,
# 8: isNewbie
1 if is_newbie else 0,
# 9: isMobileVerified
1 if is_mobile_verified else 0,
# 10: medalLevel
medal_level,
# 11: id
id_ if id_ is not None else uuid.uuid4().hex,
# 12: translation
2021-12-26 23:34:45 +08:00
translation,
# 13: contentType
content_type,
# 14: contentTypeParams
content_type_params if content_type_params is not None else [],
2023-08-27 00:23:52 +08:00
# 15: textEmoticons
2023-09-09 17:13:53 +08:00
[], # 已废弃,保留
# 16: uid
uid
2021-12-26 23:34:45 +08:00
]
def make_emoticon_params(url):
return [
# 0: url
url,
]
def make_translation_message_data(msg_id, translation):
2020-09-12 11:33:11 +08:00
return [
# 0: id
msg_id,
# 1: translation
2021-12-26 23:34:45 +08:00
translation,
2020-09-12 11:33:11 +08:00
]
2023-09-08 20:53:04 +08:00
class ChatHandler(tornado.websocket.WebSocketHandler):
2021-02-01 23:49:31 +08:00
HEARTBEAT_INTERVAL = 10
RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5
2019-05-22 01:11:23 +08:00
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
2021-02-01 23:49:31 +08:00
self._heartbeat_timer_handle = None
self._receive_timeout_timer_handle = None
self.room_key: Optional[services.chat.RoomKey] = None
2020-02-06 17:39:56 +08:00
self.auto_translate = False
2019-05-22 01:11:23 +08:00
2019-05-22 14:10:27 +08:00
def open(self):
logger.info('client=%s connected', self.request.remote_ip)
2023-07-29 12:48:57 +08:00
self._heartbeat_timer_handle = asyncio.get_running_loop().call_later(
2021-02-01 23:49:31 +08:00
self.HEARTBEAT_INTERVAL, self._on_send_heartbeat
)
self._refresh_receive_timeout_timer()
2019-05-22 14:10:27 +08:00
2021-02-01 23:49:31 +08:00
def _on_send_heartbeat(self):
self.send_cmd_data(Command.HEARTBEAT, {})
2023-07-29 12:48:57 +08:00
self._heartbeat_timer_handle = asyncio.get_running_loop().call_later(
2021-02-01 23:49:31 +08:00
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()
2023-07-29 12:48:57 +08:00
self._receive_timeout_timer_handle = asyncio.get_running_loop().call_later(
2021-02-01 23:49:31 +08:00
self.RECEIVE_TIMEOUT, self._on_receive_timeout
)
def _on_receive_timeout(self):
logger.info('client=%s timed out', self.request.remote_ip)
2021-02-01 23:49:31 +08:00
self._receive_timeout_timer_handle = None
self.close()
def on_close(self):
logger.info('client=%s disconnected, room=%s', self.request.remote_ip, self.room_key)
2021-02-01 23:49:31 +08:00
if self.has_joined_room:
services.chat.client_room_manager.del_client(self.room_key, self)
2021-02-01 23:49:31 +08:00
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
2020-02-05 17:28:10 +08:00
def on_message(self, message):
try:
body = json.loads(message)
cmd = int(body['cmd'])
2020-02-05 17:28:10 +08:00
if cmd == Command.HEARTBEAT:
2023-08-26 16:42:01 +08:00
# 超时没有加入房间也断开
if self.has_joined_room:
self._refresh_receive_timeout_timer()
2020-02-05 17:28:10 +08:00
elif cmd == Command.JOIN_ROOM:
self._on_join_room_req(body)
2020-02-05 17:28:10 +08:00
else:
logger.warning('client=%s unknown cmd=%d, body=%s', self.request.remote_ip, cmd, body)
2022-02-15 00:18:46 +08:00
except Exception: # noqa
logger.exception('client=%s on_message error, message=%s', self.request.remote_ip, message)
2019-05-22 01:11:23 +08:00
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()
2019-05-26 17:14:59 +08:00
# 跨域测试用
def check_origin(self, origin):
if self.application.settings['debug']:
return True
return super().check_origin(origin)
2019-10-06 16:20:48 +08:00
@property
def has_joined_room(self):
return self.room_key is not None
def send_cmd_data(self, cmd, data):
self.send_body_no_raise(make_message_body(cmd, data))
def send_body_no_raise(self, body: Union[bytes, str, Dict[str, Any]]):
try:
self.write_message(body)
except tornado.websocket.WebSocketClosedError:
2021-02-01 23:49:31 +08:00
self.close()
async def _on_joined_room(self):
2022-02-27 22:05:37 +08:00
if self.settings['debug']:
await self._send_test_message()
# 不允许自动翻译的提示
if self.auto_translate:
cfg = config.get_config()
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(
2021-12-26 23:34:45 +08:00
author_name='blivechat',
author_type=2,
content='Translation is not allowed in this room. Please download to use translation',
author_level=60,
))
2019-10-06 16:20:48 +08:00
# 测试用
async def _send_test_message(self):
2019-10-06 16:20:48 +08:00
base_data = {
'avatarUrl': await services.avatar.get_avatar_url(300474, 'xfgryujk'),
2020-05-20 21:20:08 +08:00
'timestamp': int(time.time()),
2019-10-06 16:20:48 +08:00
'authorName': 'xfgryujk',
}
text_data = make_text_message_data(
2021-12-26 23:34:45 +08:00
avatar_url=base_data['avatarUrl'],
timestamp=base_data['timestamp'],
author_name=base_data['authorName'],
content='我能吞下玻璃而不伤身体',
author_level=60,
)
2020-02-06 17:39:56 +08:00
member_data = {
**base_data,
2020-07-19 21:33:26 +08:00
'id': uuid.uuid4().hex,
'privilegeType': 3
2020-02-06 17:39:56 +08:00
}
2019-10-06 16:20:48 +08:00
gift_data = {
**base_data,
2020-02-06 17:39:56 +08:00
'id': uuid.uuid4().hex,
'totalCoin': 450000,
'giftName': '摩天大楼',
'num': 1
2019-10-06 16:20:48 +08:00
}
sc_data = {
**base_data,
2020-02-06 17:39:56 +08:00
'id': str(random.randint(1, 65535)),
2020-02-05 17:28:10 +08:00
'price': 30,
2019-10-06 16:20:48 +08:00
'content': 'The quick brown fox jumps over the lazy dog',
2020-02-06 17:39:56 +08:00
'translation': ''
2019-10-06 16:20:48 +08:00
}
self.send_cmd_data(Command.ADD_TEXT, text_data)
2023-08-27 00:23:52 +08:00
text_data[4] = 'te[dog]st'
text_data[11] = uuid.uuid4().hex
self.send_cmd_data(Command.ADD_TEXT, text_data)
2020-02-05 17:28:10 +08:00
text_data[2] = '主播'
text_data[3] = 3
text_data[4] = "I can eat glass, it doesn't hurt me."
2020-02-06 17:39:56 +08:00
text_data[11] = uuid.uuid4().hex
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)
2020-02-06 17:39:56 +08:00
sc_data['id'] = str(random.randint(1, 65535))
2019-10-06 16:20:48 +08:00
sc_data['price'] = 100
sc_data['content'] = '敏捷的棕色狐狸跳过了懒狗'
self.send_cmd_data(Command.ADD_SUPER_CHAT, sc_data)
2022-03-16 22:20:41 +08:00
# self.send_cmd_data(Command.DEL_SUPER_CHAT, {'ids': [sc_data['id']]})
self.send_cmd_data(Command.ADD_GIFT, gift_data)
2020-02-06 17:39:56 +08:00
gift_data['id'] = uuid.uuid4().hex
2019-10-06 16:20:48 +08:00
gift_data['totalCoin'] = 1245000
gift_data['giftName'] = '小电视飞船'
self.send_cmd_data(Command.ADD_GIFT, gift_data)
2020-09-13 21:24:20 +08:00
2023-09-08 20:53:04 +08:00
class RoomInfoHandler(api.base.ApiHandler):
2020-09-13 21:24:20 +08:00
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)
2020-09-13 21:24:20 +08:00
room_id, owner_uid = await self._get_room_info(room_id)
# 连接其他host必须要key
host_server_list = dm_web_cli.DEFAULT_DANMAKU_SERVER_LIST
2020-09-13 21:24:20 +08:00
if owner_uid == 0:
# 缓存3分钟
self.set_header('Cache-Control', 'private, max-age=180')
else:
# 缓存1天
self.set_header('Cache-Control', 'private, max-age=86400')
self.write({
'roomId': room_id,
'ownerUid': owner_uid,
'hostServerList': host_server_list
})
@staticmethod
async def _get_room_info(room_id):
try:
2022-02-15 00:18:46 +08:00
async with utils.request.http_session.get(
2023-09-03 22:53:15 +08:00
dm_web_cli.ROOM_INIT_URL,
headers={
**utils.request.BILIBILI_COMMON_HEADERS,
'Origin': 'https://live.bilibili.com',
'Referer': f'https://live.bilibili.com/{room_id}'
},
params={
'room_id': room_id
}
2022-02-15 00:18:46 +08:00
) as res:
2020-09-13 21:24:20 +08:00
if res.status != 200:
logger.warning('room=%d _get_room_info failed: %d %s', room_id,
2020-09-13 21:24:20 +08:00
res.status, res.reason)
return room_id, 0
data = await res.json()
2021-03-28 18:30:31 +08:00
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
logger.exception('room=%d _get_room_info failed', room_id)
2020-09-13 21:24:20 +08:00
return room_id, 0
if data['code'] != 0:
logger.warning('room=%d _get_room_info failed: %s', room_id, data['message'])
2020-09-13 21:24:20 +08:00
return room_id, 0
room_info = data['data']['room_info']
return room_info['room_id'], room_info['uid']
2023-09-08 20:53:04 +08:00
class AvatarHandler(api.base.ApiHandler):
2020-09-13 21:24:20 +08:00
async def get(self):
uid = int(self.get_query_argument('uid'))
username = self.get_query_argument('username', '')
2022-02-15 00:18:46 +08:00
avatar_url = await services.avatar.get_avatar_url_or_none(uid)
2020-09-13 21:24:20 +08:00
if avatar_url is None:
avatar_url = services.avatar.get_default_avatar_url(uid, username)
2020-09-13 21:24:20 +08:00
# 缓存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})
2023-09-08 20:53:04 +08:00
2023-09-08 23:00:45 +08:00
class TextEmoticonMappingsHandler(api.base.ApiHandler):
async def get(self):
# 缓存1天
self.set_header('Cache-Control', 'private, max-age=86400')
2023-09-08 23:00:45 +08:00
cfg = config.get_config()
self.write({'textEmoticons': cfg.text_emoticons})
2023-09-08 20:53:04 +08:00
ROUTES = [
(r'/api/chat', ChatHandler),
(r'/api/room_info', RoomInfoHandler),
(r'/api/avatar_url', AvatarHandler),
2023-09-08 23:00:45 +08:00
(r'/api/text_emoticon_mappings', TextEmoticonMappingsHandler),
2023-09-08 20:53:04 +08:00
]