2019-05-22 01:11:23 +08:00
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
2019-05-22 14:10:27 +08:00
|
|
|
|
import asyncio
|
2019-06-20 20:03:07 +08:00
|
|
|
|
import datetime
|
2019-05-22 01:11:23 +08:00
|
|
|
|
import enum
|
|
|
|
|
import json
|
2019-05-22 14:10:27 +08:00
|
|
|
|
import logging
|
2019-05-22 19:38:36 +08:00
|
|
|
|
import time
|
2019-05-22 01:11:23 +08:00
|
|
|
|
from typing import *
|
|
|
|
|
|
2019-05-22 15:42:45 +08:00
|
|
|
|
import aiohttp
|
2019-05-22 01:11:23 +08:00
|
|
|
|
import tornado.websocket
|
|
|
|
|
|
|
|
|
|
import blivedm.blivedm as blivedm
|
|
|
|
|
|
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
|
2019-05-22 01:11:23 +08:00
|
|
|
|
|
|
|
|
|
|
2019-07-01 18:32:54 +08:00
|
|
|
|
DEFAULT_AVATAR_URL = 'https://static.hdslb.com/images/member/noface.gif'
|
|
|
|
|
|
2019-05-26 17:14:59 +08:00
|
|
|
|
_http_session = aiohttp.ClientSession()
|
2019-05-22 15:42:45 +08:00
|
|
|
|
_avatar_url_cache: Dict[int, str] = {}
|
2019-07-01 18:32:54 +08:00
|
|
|
|
_last_fetch_avatar_time = datetime.datetime.now()
|
2019-06-20 20:03:07 +08:00
|
|
|
|
_last_avatar_failed_time = None
|
2019-07-01 18:32:54 +08:00
|
|
|
|
_uids_to_fetch_avatar = asyncio.Queue(15)
|
2019-05-22 15:42:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_avatar_url(user_id):
|
|
|
|
|
if user_id in _avatar_url_cache:
|
|
|
|
|
return _avatar_url_cache[user_id]
|
2019-06-20 20:03:07 +08:00
|
|
|
|
|
2019-07-01 18:32:54 +08:00
|
|
|
|
global _last_avatar_failed_time, _last_fetch_avatar_time
|
|
|
|
|
cur_time = datetime.datetime.now()
|
|
|
|
|
# 防止获取头像频率太高被ban
|
|
|
|
|
if (cur_time - _last_fetch_avatar_time).total_seconds() < 0.2:
|
|
|
|
|
# 由_fetch_avatar_loop过一段时间再获取并缓存
|
|
|
|
|
try:
|
|
|
|
|
_uids_to_fetch_avatar.put_nowait(user_id)
|
|
|
|
|
except asyncio.QueueFull:
|
|
|
|
|
pass
|
|
|
|
|
return DEFAULT_AVATAR_URL
|
|
|
|
|
|
2019-06-20 20:03:07 +08:00
|
|
|
|
if _last_avatar_failed_time is not None:
|
2019-09-22 17:18:34 +08:00
|
|
|
|
if (cur_time - _last_avatar_failed_time).total_seconds() < 3 * 60 + 3:
|
|
|
|
|
# 3分钟以内被ban,解封大约要15分钟
|
2019-07-01 18:32:54 +08:00
|
|
|
|
return DEFAULT_AVATAR_URL
|
2019-06-20 20:03:07 +08:00
|
|
|
|
else:
|
|
|
|
|
_last_avatar_failed_time = None
|
|
|
|
|
|
2019-07-01 18:32:54 +08:00
|
|
|
|
_last_fetch_avatar_time = cur_time
|
|
|
|
|
try:
|
|
|
|
|
async with _http_session.get('https://api.bilibili.com/x/space/acc/info',
|
|
|
|
|
params={'mid': user_id}) as r:
|
|
|
|
|
if r.status != 200: # 可能会被B站ban
|
2019-07-18 09:08:03 +08:00
|
|
|
|
logger.warning('Failed to fetch avatar: status=%d %s uid=%d', r.status, r.reason, user_id)
|
2019-07-01 18:32:54 +08:00
|
|
|
|
_last_avatar_failed_time = cur_time
|
|
|
|
|
return DEFAULT_AVATAR_URL
|
|
|
|
|
data = await r.json()
|
2019-07-03 00:12:40 +08:00
|
|
|
|
except aiohttp.ClientConnectionError:
|
2019-07-01 18:32:54 +08:00
|
|
|
|
return DEFAULT_AVATAR_URL
|
2019-05-22 15:42:45 +08:00
|
|
|
|
url = data['data']['face']
|
|
|
|
|
if not url.endswith('noface.gif'):
|
2019-06-13 23:50:50 +08:00
|
|
|
|
url += '@48w_48h'
|
2019-05-22 15:42:45 +08:00
|
|
|
|
_avatar_url_cache[user_id] = url
|
|
|
|
|
|
2019-06-20 20:03:07 +08:00
|
|
|
|
if len(_avatar_url_cache) > 50000:
|
2019-05-22 15:42:45 +08:00
|
|
|
|
for _, key in zip(range(100), _avatar_url_cache):
|
|
|
|
|
del _avatar_url_cache[key]
|
|
|
|
|
|
|
|
|
|
return url
|
|
|
|
|
|
|
|
|
|
|
2019-07-01 18:32:54 +08:00
|
|
|
|
async def _fetch_avatar_loop():
|
|
|
|
|
while True:
|
|
|
|
|
try:
|
|
|
|
|
user_id = await _uids_to_fetch_avatar.get()
|
|
|
|
|
if user_id in _avatar_url_cache:
|
|
|
|
|
continue
|
|
|
|
|
# 延时长一些使实时弹幕有机会获取头像
|
|
|
|
|
await asyncio.sleep(0.4 - (datetime.datetime.now() - _last_fetch_avatar_time).total_seconds())
|
|
|
|
|
asyncio.ensure_future(get_avatar_url(user_id))
|
|
|
|
|
except:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
asyncio.ensure_future(_fetch_avatar_loop())
|
|
|
|
|
|
|
|
|
|
|
2019-05-22 01:11:23 +08:00
|
|
|
|
class Room(blivedm.BLiveClient):
|
|
|
|
|
def __init__(self, room_id):
|
2019-09-17 23:08:03 +08:00
|
|
|
|
super().__init__(room_id, session=_http_session, heartbeat_interval=10)
|
2019-05-22 01:11:23 +08:00
|
|
|
|
self.clients: List['ChatHandler'] = []
|
2019-06-06 22:13:45 +08:00
|
|
|
|
|
|
|
|
|
def stop_and_close(self):
|
2019-06-21 17:38:22 +08:00
|
|
|
|
if self.is_running:
|
|
|
|
|
future = self.stop()
|
|
|
|
|
future.add_done_callback(lambda _future: asyncio.ensure_future(self.close()))
|
|
|
|
|
else:
|
|
|
|
|
asyncio.ensure_future(self.close())
|
2019-05-22 20:54:42 +08:00
|
|
|
|
|
2019-05-22 01:11:23 +08:00
|
|
|
|
def send_message(self, cmd, data):
|
|
|
|
|
body = json.dumps({'cmd': cmd, 'data': data})
|
|
|
|
|
for client in self.clients:
|
|
|
|
|
client.write_message(body)
|
|
|
|
|
|
2019-06-06 22:13:45 +08:00
|
|
|
|
async def _on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage):
|
2019-07-04 19:12:24 +08:00
|
|
|
|
asyncio.ensure_future(self.__on_receive_danmaku(danmaku))
|
|
|
|
|
|
|
|
|
|
async def __on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage):
|
2019-06-06 22:13:45 +08:00
|
|
|
|
if danmaku.uid == self.room_owner_uid:
|
2019-05-22 20:54:42 +08:00
|
|
|
|
author_type = 3 # 主播
|
2019-06-06 22:13:45 +08:00
|
|
|
|
elif danmaku.admin:
|
2019-05-22 20:54:42 +08:00
|
|
|
|
author_type = 2 # 房管
|
2019-06-06 22:13:45 +08:00
|
|
|
|
elif danmaku.privilege_type != 0: # 1总督,2提督,3舰长
|
2019-05-22 20:54:42 +08:00
|
|
|
|
author_type = 1 # 舰队
|
|
|
|
|
else:
|
|
|
|
|
author_type = 0
|
2019-05-22 19:38:36 +08:00
|
|
|
|
self.send_message(Command.ADD_TEXT, {
|
2019-06-06 22:13:45 +08:00
|
|
|
|
'avatarUrl': await get_avatar_url(danmaku.uid),
|
|
|
|
|
'timestamp': danmaku.timestamp,
|
|
|
|
|
'authorName': danmaku.uname,
|
2019-05-22 20:54:42 +08:00
|
|
|
|
'authorType': author_type,
|
2019-06-12 19:20:17 +08:00
|
|
|
|
'content': danmaku.msg,
|
|
|
|
|
'privilegeType': danmaku.privilege_type,
|
|
|
|
|
'isGiftDanmaku': bool(danmaku.msg_type),
|
|
|
|
|
'authorLevel': danmaku.user_level,
|
|
|
|
|
'isNewbie': danmaku.urank < 10000,
|
2019-06-21 17:38:22 +08:00
|
|
|
|
'isMobileVerified': bool(danmaku.mobile_verify),
|
|
|
|
|
'medalLevel': 0 if danmaku.room_id != self.room_id else danmaku.medal_level
|
2019-05-22 19:38:36 +08:00
|
|
|
|
})
|
2019-05-22 01:11:23 +08:00
|
|
|
|
|
2019-06-06 22:13:45 +08:00
|
|
|
|
async def _on_receive_gift(self, gift: blivedm.GiftMessage):
|
|
|
|
|
if gift.coin_type != 'gold': # 丢人
|
2019-05-22 19:38:36 +08:00
|
|
|
|
return
|
|
|
|
|
self.send_message(Command.ADD_GIFT, {
|
2019-07-01 18:32:54 +08:00
|
|
|
|
'avatarUrl': gift.face,
|
2019-06-12 19:20:17 +08:00
|
|
|
|
'timestamp': gift.timestamp,
|
2019-06-06 22:13:45 +08:00
|
|
|
|
'authorName': gift.uname,
|
|
|
|
|
'giftName': gift.gift_name,
|
|
|
|
|
'giftNum': gift.num,
|
|
|
|
|
'totalCoin': gift.total_coin
|
2019-05-22 19:38:36 +08:00
|
|
|
|
})
|
|
|
|
|
|
2019-06-06 22:13:45 +08:00
|
|
|
|
async def _on_buy_guard(self, message: blivedm.GuardBuyMessage):
|
2019-07-04 19:12:24 +08:00
|
|
|
|
asyncio.ensure_future(self.__on_buy_guard(message))
|
|
|
|
|
|
|
|
|
|
async def __on_buy_guard(self, message: blivedm.GuardBuyMessage):
|
2019-06-20 20:03:07 +08:00
|
|
|
|
self.send_message(Command.ADD_MEMBER, {
|
2019-06-06 22:13:45 +08:00
|
|
|
|
'avatarUrl': await get_avatar_url(message.uid),
|
2019-06-12 19:20:17 +08:00
|
|
|
|
'timestamp': message.start_time,
|
2019-06-06 22:13:45 +08:00
|
|
|
|
'authorName': message.username
|
2019-05-22 19:38:36 +08:00
|
|
|
|
})
|
|
|
|
|
|
2019-09-23 22:22:27 +08:00
|
|
|
|
async def _on_super_chat(self, message: blivedm.SuperChatMessage):
|
|
|
|
|
self.send_message(Command.ADD_SUPER_CHAT, {
|
|
|
|
|
'avatarUrl': message.face,
|
|
|
|
|
'timestamp': message.start_time,
|
|
|
|
|
'authorName': message.uname,
|
|
|
|
|
'price': message.price,
|
|
|
|
|
'content': message.message,
|
|
|
|
|
'id': message.id
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async def _on_super_chat_delete(self, message: blivedm.SuperChatDeleteMessage):
|
|
|
|
|
self.send_message(Command.ADD_SUPER_CHAT, {
|
|
|
|
|
'ids': message.ids
|
|
|
|
|
})
|
|
|
|
|
|
2019-05-22 01:11:23 +08:00
|
|
|
|
|
|
|
|
|
class RoomManager:
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self._rooms: Dict[int, Room] = {}
|
|
|
|
|
|
2019-10-06 16:20:48 +08:00
|
|
|
|
async def add_client(self, room_id, client: 'ChatHandler'):
|
|
|
|
|
if room_id not in self._rooms:
|
|
|
|
|
if not await self._add_room(room_id):
|
|
|
|
|
client.close()
|
|
|
|
|
return
|
|
|
|
|
room = self._rooms[room_id]
|
2019-05-22 01:11:23 +08:00
|
|
|
|
room.clients.append(client)
|
2019-10-06 16:20:48 +08:00
|
|
|
|
logger.info('%d clients in room %s', len(room.clients), room_id)
|
2019-05-22 01:11:23 +08:00
|
|
|
|
|
2019-05-26 17:14:59 +08:00
|
|
|
|
if client.application.settings['debug']:
|
2019-10-06 16:20:48 +08:00
|
|
|
|
client.send_test_message()
|
2019-05-22 19:38:36 +08:00
|
|
|
|
|
2019-05-22 01:11:23 +08:00
|
|
|
|
def del_client(self, room_id, client: 'ChatHandler'):
|
|
|
|
|
if room_id not in self._rooms:
|
|
|
|
|
return
|
|
|
|
|
room = self._rooms[room_id]
|
|
|
|
|
room.clients.remove(client)
|
2019-10-06 16:20:48 +08:00
|
|
|
|
logger.info('%d clients in room %s', len(room.clients), room_id)
|
2019-05-22 01:11:23 +08:00
|
|
|
|
if not room.clients:
|
2019-10-06 16:20:48 +08:00
|
|
|
|
self._del_room(room_id)
|
2019-05-22 01:11:23 +08:00
|
|
|
|
|
2019-10-06 16:20:48 +08:00
|
|
|
|
async def _add_room(self, room_id):
|
|
|
|
|
if room_id in self._rooms:
|
|
|
|
|
return True
|
|
|
|
|
logger.info('Creating room %d', room_id)
|
|
|
|
|
room = Room(room_id)
|
|
|
|
|
self._rooms[room_id] = room
|
|
|
|
|
if await room.init_room():
|
|
|
|
|
room.start()
|
|
|
|
|
return True
|
|
|
|
|
else:
|
|
|
|
|
self._del_room(room_id)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def _del_room(self, room_id):
|
|
|
|
|
if room_id not in self._rooms:
|
|
|
|
|
return
|
|
|
|
|
logger.info('Removing room %d', room_id)
|
|
|
|
|
room = self._rooms[room_id]
|
|
|
|
|
for client in room.clients:
|
|
|
|
|
client.close()
|
|
|
|
|
room.stop_and_close()
|
|
|
|
|
del self._rooms[room_id]
|
2019-05-22 19:38:36 +08:00
|
|
|
|
|
2019-05-22 01:11:23 +08:00
|
|
|
|
|
|
|
|
|
room_manager = RoomManager()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# noinspection PyAbstractClass
|
|
|
|
|
class ChatHandler(tornado.websocket.WebSocketHandler):
|
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
self.room_id = None
|
|
|
|
|
|
2019-05-22 14:10:27 +08:00
|
|
|
|
def open(self):
|
2019-07-18 09:08:03 +08:00
|
|
|
|
logger.info('Websocket connected %s', self.request.remote_ip)
|
2019-05-22 14:10:27 +08:00
|
|
|
|
|
2019-05-22 01:11:23 +08:00
|
|
|
|
def on_message(self, message):
|
|
|
|
|
body = json.loads(message)
|
2019-10-06 16:20:48 +08:00
|
|
|
|
cmd = body['cmd']
|
|
|
|
|
if cmd == Command.HEARTBEAT:
|
|
|
|
|
pass
|
|
|
|
|
elif cmd == Command.JOIN_ROOM:
|
|
|
|
|
if self.room_id is not None:
|
|
|
|
|
return
|
2019-05-22 14:10:27 +08:00
|
|
|
|
self.room_id = int(body['data']['roomId'])
|
2019-07-18 09:08:03 +08:00
|
|
|
|
logger.info('Client %s is joining room %d', self.request.remote_ip, self.room_id)
|
2019-10-06 16:20:48 +08:00
|
|
|
|
asyncio.ensure_future(room_manager.add_client(self.room_id, self))
|
2019-05-22 14:10:27 +08:00
|
|
|
|
else:
|
2019-10-06 16:20:48 +08:00
|
|
|
|
logger.warning('Unknown cmd: %s body: %s', cmd, body)
|
2019-05-22 01:11:23 +08:00
|
|
|
|
|
|
|
|
|
def on_close(self):
|
2019-10-06 16:20:48 +08:00
|
|
|
|
logger.info('Websocket disconnected %s room: %s', self.request.remote_ip, str(self.room_id))
|
2019-05-22 01:11:23 +08:00
|
|
|
|
if self.room_id is not None:
|
|
|
|
|
room_manager.del_client(self.room_id, self)
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
# 测试用
|
|
|
|
|
def send_test_message(self):
|
|
|
|
|
base_data = {
|
|
|
|
|
'avatarUrl': 'https://i0.hdslb.com/bfs/face/29b6be8aa611e70a3d3ac219cdaf5e72b604f2de.jpg@48w_48h',
|
|
|
|
|
'timestamp': time.time(),
|
|
|
|
|
'authorName': 'xfgryujk',
|
|
|
|
|
}
|
|
|
|
|
text_data = {
|
|
|
|
|
**base_data,
|
|
|
|
|
'authorType': 0,
|
|
|
|
|
'content': '我能吞下玻璃而不伤身体',
|
|
|
|
|
'privilegeType': 0,
|
|
|
|
|
'isGiftDanmaku': False,
|
|
|
|
|
'authorLevel': 20,
|
|
|
|
|
'isNewbie': False,
|
|
|
|
|
'isMobileVerified': True
|
|
|
|
|
}
|
|
|
|
|
member_data = base_data
|
|
|
|
|
gift_data = {
|
|
|
|
|
**base_data,
|
|
|
|
|
'giftName': '摩天大楼',
|
|
|
|
|
'giftNum': 1,
|
|
|
|
|
'totalCoin': 450000
|
|
|
|
|
}
|
|
|
|
|
sc_data = {
|
|
|
|
|
**base_data,
|
|
|
|
|
'price': 30,
|
|
|
|
|
'content': 'The quick brown fox jumps over the lazy dog',
|
|
|
|
|
'id': 1
|
|
|
|
|
}
|
|
|
|
|
self.send_message(Command.ADD_TEXT, text_data)
|
|
|
|
|
text_data['authorName'] = '主播'
|
|
|
|
|
text_data['authorType'] = 3
|
|
|
|
|
text_data['content'] = "I can eat glass, it doesn't hurt me."
|
|
|
|
|
self.send_message(Command.ADD_TEXT, text_data)
|
|
|
|
|
self.send_message(Command.ADD_MEMBER, member_data)
|
|
|
|
|
self.send_message(Command.ADD_SUPER_CHAT, sc_data)
|
|
|
|
|
sc_data['price'] = 100
|
|
|
|
|
sc_data['content'] = '敏捷的棕色狐狸跳过了懒狗'
|
|
|
|
|
sc_data['id'] = 2
|
|
|
|
|
self.send_message(Command.ADD_SUPER_CHAT, sc_data)
|
|
|
|
|
# self.send_message(Command.DEL_SUPER_CHAT, {'ids': [1, 2]})
|
|
|
|
|
self.send_message(Command.ADD_GIFT, gift_data)
|
|
|
|
|
gift_data['giftName'] = '小电视飞船'
|
|
|
|
|
gift_data['totalCoin'] = 1245000
|
|
|
|
|
self.send_message(Command.ADD_GIFT, gift_data)
|
|
|
|
|
|
|
|
|
|
def send_message(self, cmd, data):
|
|
|
|
|
body = json.dumps({'cmd': cmd, 'data': data})
|
|
|
|
|
self.write_message(body)
|