diff --git a/docker-dev/tbot/nginx.conf b/docker-dev/tbot/nginx.conf
index ce5e59c..1aad335 100644
--- a/docker-dev/tbot/nginx.conf
+++ b/docker-dev/tbot/nginx.conf
@@ -14,18 +14,18 @@ events {
http {
include /etc/nginx/mime.types;
log_format apm '"$time_iso8601" client="$proxy_add_x_forwarded_for" '
- 'method=$request_method request="$request" '
- 'request_length=$request_length '
- 'status=$status bytes_sent=$bytes_sent '
- 'body_bytes_sent=$body_bytes_sent '
- 'referer=$http_referer '
- 'user_agent="$http_user_agent" '
- 'upstream_addr=$upstream_addr '
- 'upstream_status=$upstream_status '
- 'request_time=$request_time '
- 'upstream_response_time=$upstream_response_time '
- 'upstream_connect_time=$upstream_connect_time '
- 'upstream_header_time=$upstream_header_time';
+ 'method=$request_method request="$request" '
+ 'request_length=$request_length '
+ 'status=$status bytes_sent=$bytes_sent '
+ 'body_bytes_sent=$body_bytes_sent '
+ 'referer=$http_referer '
+ 'user_agent="$http_user_agent" '
+ 'upstream_addr=$upstream_addr '
+ 'upstream_status=$upstream_status '
+ 'request_time=$request_time '
+ 'upstream_response_time=$upstream_response_time '
+ 'upstream_connect_time=$upstream_connect_time '
+ 'upstream_header_time=$upstream_header_time';
access_log /var/log/nginx/access.log apm;
error_log /var/log/nginx/error.log;
sendfile on;
@@ -34,7 +34,7 @@ http {
proxy_read_timeout 30s;
tcp_nopush on;
proxy_headers_hash_max_size 512;
-
+
##
# Gzip Settings
##
@@ -66,19 +66,19 @@ http {
# Compression level (1-9)
# 5 is the perfect compromise between size and CPU usage
gzip_comp_level 5;
-
+
# gzip_buffers 16 8k;
# gzip_http_version 1.1;
gzip_types
- text/plain
- text/css
- application/json
- application/javascript
- application/x-javascript
- text/xml
- application/xml
- application/xml+rss
- text/javascript;
+ text/plain
+ text/css
+ application/json
+ application/javascript
+ application/x-javascript
+ text/xml
+ application/xml
+ application/xml+rss
+ text/javascript;
proxy_next_upstream error;
@@ -117,5 +117,15 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Scheme $scheme;
}
+ location /api/live-chat {
+ proxy_pass http://frontends;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_redirect off;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Scheme $scheme;
+ }
}
}
diff --git a/package-lock.json b/package-lock.json
index 9c62783..6933902 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,7 +16,8 @@
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-config": "^5.1.1",
- "react-router-dom": "^5.3.0"
+ "react-router-dom": "^5.3.0",
+ "react-use-websocket": "^3.0.0"
},
"devDependencies": {
"@babel/core": "^7.15.5",
@@ -5335,6 +5336,15 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
+ "node_modules/react-use-websocket": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-3.0.0.tgz",
+ "integrity": "sha512-BInlbhXYrODBPKIplDAmI0J1VPM+1KhCLN09o+dzgQ8qMyrYs4t5kEYmCrTqyRuMTmpahylHFZWQXpfYyDkqOw==",
+ "peerDependencies": {
+ "react": ">= 16.8.0",
+ "react-dom": ">= 16.8.0"
+ }
+ },
"node_modules/read-pkg": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
@@ -10768,6 +10778,12 @@
"tiny-warning": "^1.0.0"
}
},
+ "react-use-websocket": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-3.0.0.tgz",
+ "integrity": "sha512-BInlbhXYrODBPKIplDAmI0J1VPM+1KhCLN09o+dzgQ8qMyrYs4t5kEYmCrTqyRuMTmpahylHFZWQXpfYyDkqOw==",
+ "requires": {}
+ },
"read-pkg": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
diff --git a/package.json b/package.json
index c5f8d3c..4ef1019 100644
--- a/package.json
+++ b/package.json
@@ -25,13 +25,13 @@
"babel-loader": "^8.2.2",
"css-loader": "^6.2.0",
"html-loader": "^2.1.2",
+ "html-webpack-plugin": "^5.3.2",
"mini-css-extract-plugin": "^2.2.2",
"node-sass": "^7.0.1",
"prop-types": "^15.7.2",
"sass-loader": "^12.1.0",
"webpack": "^5.52.0",
- "webpack-cli": "^4.8.0",
- "html-webpack-plugin": "^5.3.2"
+ "webpack-cli": "^4.8.0"
},
"dependencies": {
"axios": "^0.21.3",
@@ -41,6 +41,7 @@
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-config": "^5.1.1",
- "react-router-dom": "^5.3.0"
+ "react-router-dom": "^5.3.0",
+ "react-use-websocket": "^3.0.0"
}
}
diff --git a/tbot/twitch_bot/bot_base.py b/tbot/twitch_bot/bot_base.py
index 840bb97..2c6b7e8 100644
--- a/tbot/twitch_bot/bot_base.py
+++ b/tbot/twitch_bot/bot_base.py
@@ -1,12 +1,18 @@
-import random, bottom
-import asyncio, aiohttp
+import asyncio
+import random
from datetime import datetime
-from tbot.twitch_bot.unpack import rfc2812_handler
+from typing import Optional
+
+import aiohttp
+import bottom
+from aioredis import Redis
+
+from tbot import config, logger, utils
from tbot.twitch_bot import bot_sender
-from tbot import config, utils, logger
+from tbot.twitch_bot.unpack import rfc2812_handler
-class Client(bottom.Client):
+class Client(bottom.Client):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -16,7 +22,7 @@ def __init__(self, *args, **kwargs):
self.raw_handlers = [rfc2812_handler(self)]
self.ahttp = None
self.db = None
- self.redis = None
+ self.redis: Optional[Redis] = None
self.redis_sub = None
self.pong_check_task = None
self.ping_task = None
@@ -24,10 +30,10 @@ def __init__(self, *args, **kwargs):
self.user = None
self.bot_sender = None
- self.host = config.data.twitch.irc_host
- self.port = config.data.twitch.irc_port
+ self.host = config.data.twitch.irc_host
+ self.port = config.data.twitch.irc_port
self.ssl = config.data.twitch.irc_use_ssl
-
+
def send(self, command: str, **kwargs) -> None:
if self.rate_limit_count < config.data.twitch.irc_rate_limit:
if command == 'PRIVMSG' and bot.bot_sender:
@@ -36,11 +42,15 @@ def send(self, command: str, **kwargs) -> None:
super().send(command, **kwargs)
self.rate_limit_count += 1
else:
- self.rate_limit_bucket.append({
- 'command': command,
- 'kwargs': kwargs,
- })
- logger.warning(f'Rate limit reached. In queue: {len(self.rate_limit_bucket)}')
+ self.rate_limit_bucket.append(
+ {
+ 'command': command,
+ 'kwargs': kwargs,
+ }
+ )
+ logger.warning(
+ f'Rate limit reached. In queue: {len(self.rate_limit_bucket)}'
+ )
async def rate_limit_reset_runner(self):
while True:
@@ -65,7 +75,7 @@ async def send_ping(self, time=None):
async def wait_for_pong(self):
await asyncio.sleep(10)
- logger.error('Didn\'t receive a PONG in time, reconnecting')
+ logger.error("Didn't receive a PONG in time, reconnecting")
await self.connect()
async def connect(self) -> None:
@@ -74,12 +84,14 @@ async def connect(self) -> None:
self.ping_task = asyncio.create_task(self.send_ping(10))
return await super().connect()
+
bot = Client('a', 0)
+
@bot.on('CLIENT_CONNECT')
async def connect(**kwargs):
- if not bot.is_running:
- bot.ahttp = aiohttp.ClientSession()
+ if not bot.is_running:
+ bot.ahttp = aiohttp.ClientSession()
bot.loop.create_task(bot.rate_limit_reset_runner())
bot.user = await utils.twitch_current_user(bot.ahttp)
bot.redis_sub = await bot.redis.subscribe('tbot:server:commands')
@@ -89,11 +101,13 @@ async def connect(**kwargs):
bot_sender.setup(bot)
bot.loop.create_task(bot_sender.bot_sender.connect())
- logger.info('IRC Connecting to {}:{} as {}'.format(
- config.data.twitch.irc_host,
- config.data.twitch.irc_port,
- bot.user['login'],
- ))
+ logger.info(
+ 'IRC Connecting to {}:{} as {}'.format(
+ config.data.twitch.irc_host,
+ config.data.twitch.irc_port,
+ bot.user['login'],
+ )
+ )
try:
if config.data.twitch.chat_token:
bot.send('PASS', password='oauth:{}'.format(config.data.twitch.chat_token))
@@ -108,10 +122,9 @@ async def connect(**kwargs):
# Don't try to join channels until the server has
# sent the MOTD, or signaled that there's no MOTD.
done, pending = await asyncio.wait(
- [bot.wait("RPL_ENDOFMOTD"),
- bot.wait("ERR_NOMOTD")],
+ [bot.wait('RPL_ENDOFMOTD'), bot.wait('ERR_NOMOTD')],
loop=bot.loop,
- return_when=asyncio.FIRST_COMPLETED
+ return_when=asyncio.FIRST_COMPLETED,
)
bot.send_raw('CAP REQ :twitch.tv/tags')
@@ -122,9 +135,10 @@ async def connect(**kwargs):
future.cancel()
bot.trigger('AFTER_CONNECTED')
+
async def receive_redis_server_commands():
sub = bot.redis_sub[0]
- while (await sub.wait_message()):
+ while await sub.wait_message():
try:
msg = await sub.get_json()
logger.debug('Received server command: {}'.format(msg))
@@ -137,15 +151,18 @@ async def receive_redis_server_commands():
except:
logger.exception('receive_redis_server_commands')
+
@bot.on('CLIENT_DISCONNECT')
async def disconnect(**kwargs):
logger.info('Disconnected')
+
@bot.on('PING')
def keepalive(message, **kwargs):
logger.debug('Received ping, sending PONG back')
bot.send('PONG', message=message)
+
@bot.on('PONG')
async def pong(message, **kwargs):
logger.debug('Received pong')
@@ -158,17 +175,19 @@ async def pong(message, **kwargs):
bot.channels = {}
+
@bot.on('AFTER_CONNECTED')
async def join(**kwargs):
bot.channels = await get_channels()
- # From what I can find you are allowed to
+ # From what I can find you are allowed to
# join 50 channels every 15 seconds
for c in bot.channels.values():
- bot.send('JOIN', channel='#'+c['name'])
- asyncio.create_task(utils.twitch_save_mods(bot, c['channel_id']))
+ bot.send('JOIN', channel='#' + c['name'])
+ asyncio.create_task(utils.twitch_save_mods(bot, c['channel_id']))
await asyncio.sleep(0.20)
bot.trigger('AFTER_CHANNELS_JOINED')
- asyncio.create_task(timer_update_mods())
+ asyncio.create_task(timer_update_mods())
+
async def timer_update_mods():
await asyncio.sleep(1800)
@@ -176,15 +195,16 @@ async def timer_update_mods():
for c in bot.channels.values():
await utils.twitch_save_mods(bot, c['channel_id'])
+
async def get_channels():
- rows = await bot.db.fetchall('''
+ rows = await bot.db.fetchall("""
SELECT
c.channel_id, c.name, c.muted, c.chatlog_enabled, twitch_scope
FROM
twitch_channels c
WHERE
c.active="Y";
- ''')
+ """)
l = {}
for r in rows:
l[r['channel_id']] = {
@@ -192,34 +212,52 @@ async def get_channels():
'name': r['name'].lower(),
'muted': r['muted'] == 'Y',
'chatlog_enabled': r['chatlog_enabled'] == 'Y',
- 'twitch_scopes': utils.json_loads(r['twitch_scope']) if r['twitch_scope'] else [],
+ 'twitch_scopes': utils.json_loads(r['twitch_scope'])
+ if r['twitch_scope']
+ else [],
}
return l
+
@bot.on('REDIS_SERVER_COMMAND')
async def redis_server_command(cmd, cmd_args):
try:
- if cmd not in ['join', 'part', 'mute', 'unmute', 'enable_chatlog', 'disable_chatlog']:
+ if cmd not in [
+ 'join',
+ 'part',
+ 'mute',
+ 'unmute',
+ 'enable_chatlog',
+ 'disable_chatlog',
+ ]:
return
c = await bot.db.fetchone(
- 'SELECT channel_id, name, muted, chatlog_enabled FROM twitch_channels WHERE channel_id=%s',
- (cmd_args[0])
+ 'SELECT channel_id, name, muted, chatlog_enabled FROM twitch_channels WHERE channel_id=%s',
+ (cmd_args[0]),
)
if cmd == 'join':
- bot.send('JOIN', channel='#'+c['name'])
- bot.send("PRIVMSG", target='#'+c['name'], message='/mods')
+ bot.send('JOIN', channel='#' + c['name'])
+ bot.send('PRIVMSG', target='#' + c['name'], message='/mods')
bot.channels[c['channel_id']] = {
'channel_id': c['channel_id'],
'name': c['name'].lower(),
'muted': c['muted'] == 'Y',
'chatlog_enabled': c['chatlog_enabled'] == 'Y',
}
- bot.send("PRIVMSG", target='#'+c['name'], message='I have arrived MrDestructoid')
+ bot.send(
+ 'PRIVMSG',
+ target='#' + c['name'],
+ message='I have arrived MrDestructoid',
+ )
elif cmd == 'part':
- bot.send('PART', channel='#'+c['name'])
+ bot.send('PART', channel='#' + c['name'])
del bot.channels[c['channel_id']]
- bot.send("PRIVMSG", target='#'+c['name'], message='I have been asked to leave FeelsBadMan')
- elif cmd == 'unmute':
+ bot.send(
+ 'PRIVMSG',
+ target='#' + c['name'],
+ message='I have been asked to leave FeelsBadMan',
+ )
+ elif cmd == 'unmute':
if c['channel_id'] in bot.channels:
bot.channels[c['channel_id']]['muted'] = False
elif cmd == 'mute':
@@ -232,4 +270,4 @@ async def redis_server_command(cmd, cmd_args):
if c['channel_id'] in bot.channels:
bot.channels[c['channel_id']]['chatlog_enabled'] = False
except:
- logger.exception('redis_server_command')
\ No newline at end of file
+ logger.exception('redis_server_command')
diff --git a/tbot/twitch_bot/tasks/chatlog.py b/tbot/twitch_bot/tasks/chatlog.py
index ebed898..5f31c45 100644
--- a/tbot/twitch_bot/tasks/chatlog.py
+++ b/tbot/twitch_bot/tasks/chatlog.py
@@ -1,14 +1,25 @@
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
+
+from tbot import logger, utils
from tbot.twitch_bot.bot_base import bot
-from tbot import utils, logger
+
@bot.on('PRIVMSG')
async def message(nick, target, message, **kwargs):
if not bot.channels[kwargs['room-id']]['chatlog_enabled']:
return
-
bot.loop.create_task(
- save(1, target, kwargs['room-id'], kwargs['user'], kwargs['user-id'], message, kwargs['id'])
+ save(
+ 1,
+ target,
+ kwargs['room-id'],
+ kwargs['user'],
+ kwargs['user-id'],
+ message,
+ kwargs['id'],
+ kwargs['display-name'],
+ kwargs['color'],
+ )
)
if not message.startswith('!'):
@@ -18,46 +29,95 @@ async def message(nick, target, message, **kwargs):
if is_mod and message == '!updatemods':
await utils.twitch_save_mods(bot, kwargs['room-id'])
if not bot.channels[kwargs['room-id']]['muted']:
- bot.send("PRIVMSG", target=target, message='Affirmative, {}'.format(nick))
+ bot.send('PRIVMSG', target=target, message='Affirmative, {}'.format(nick))
+
-async def save(type_, channel, channel_id, user, user_id, message, msg_id):
+async def save(
+ type_, channel, channel_id, user, user_id, message, msg_id, display_name, user_color
+):
try:
now = datetime.utcnow()
- bot.loop.create_task(bot.db.execute('''
+ bot.loop.create_task(
+ bot.db.execute(
+ """
INSERT INTO twitch_chatlog (type, created_at, channel_id, user, user_id, message, word_count, msg_id) VALUES
(%s, %s, %s, %s, %s, %s, %s, %s)
- ''', (
- type_,
- now,
- channel_id,
- user,
- user_id,
- message,
- len(message.split(' ')),
- msg_id,
- )))
-
- c = await bot.db.execute('''
+ """,
+ (
+ type_,
+ now,
+ channel_id,
+ user,
+ user_id,
+ message,
+ len(message.split(' ')),
+ msg_id,
+ ),
+ )
+ )
+
+ c = await bot.db.execute(
+ """
UPDATE twitch_user_chat_stats SET chat_messages=chat_messages+1
WHERE channel_id=%s AND user_id=%s
- ''', (channel_id, user_id,))
+ """,
+ (
+ channel_id,
+ user_id,
+ ),
+ )
if not c.rowcount:
- bot.loop.create_task(bot.db.execute('''
+ bot.loop.create_task(
+ bot.db.execute(
+ """
INSERT INTO twitch_user_chat_stats (channel_id, user_id, chat_messages)
VALUES (%s, %s, 1) ON DUPLICATE KEY UPDATE chat_messages=chat_messages+1
- ''', (channel_id, user_id,)))
+ """,
+ (
+ channel_id,
+ user_id,
+ ),
+ )
+ )
dt = now + timedelta(days=30)
u = await bot.db.execute(
'UPDATE twitch_usernames SET user_id=%s, expires=%s WHERE user=%s',
- (user_id, dt, user,)
+ (
+ user_id,
+ dt,
+ user,
+ ),
)
if not u.rowcount:
- bot.loop.create_task(bot.db.execute('''
+ bot.loop.create_task(
+ bot.db.execute(
+ """
INSERT INTO twitch_usernames (user_id, expires, user)
VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE user=VALUES(user), expires=VALUES(expires)
- ''', (user_id, dt, user,)
- ))
- except:
- logger.exception('sql')
\ No newline at end of file
+ """,
+ (
+ user_id,
+ dt,
+ user,
+ ),
+ )
+ )
+ except Exception:
+ logger.exception('sql')
+ try:
+ if bot.redis:
+ await bot.redis.publish_json(
+ f'tbot:live_chat:{channel_id}',
+ {
+ 'provider': 'twitch',
+ 'user_id': user_id,
+ 'user': display_name,
+ 'message': message,
+ 'created_at': datetime.now(tz=timezone.utc).isoformat(),
+ 'color': user_color,
+ },
+ )
+ except Exception:
+ logger.exception('sql')
diff --git a/tbot/twitch_bot/tasks/youtube_chat.py b/tbot/twitch_bot/tasks/youtube_chat.py
index a1429d1..c42ac39 100644
--- a/tbot/twitch_bot/tasks/youtube_chat.py
+++ b/tbot/twitch_bot/tasks/youtube_chat.py
@@ -114,6 +114,19 @@ async def parse_chatmessages(channel_id: str, live_chat_id: str, chat: dict):
datetime.now(tz=timezone.utc) - parse_dt(m['snippet']['publishedAt'])
).total_seconds() > 30:
continue
+
+ await bot.redis.publish_json(
+ f'tbot:live_chat:{channel_id}',
+ {
+ 'provider': 'youtube',
+ 'user_id': m['snippet']['authorChannelId'],
+ 'user': m['authorDetails']['displayName'],
+ 'message': m['snippet']['displayMessage'],
+ 'created_at': m['snippet']['publishedAt'],
+ 'color': '',
+ },
+ )
+
message = m['snippet']['displayMessage']
author = m['snippet']['authorChannelId']
author_name = m['authorDetails']['displayName']
diff --git a/tbot/web/app.py b/tbot/web/app.py
index ab4d960..def8312 100644
--- a/tbot/web/app.py
+++ b/tbot/web/app.py
@@ -1,15 +1,18 @@
-from functools import partial
+import asyncio
import logging
import os
-import asyncio
-import aioredis
-import aiohttp
import signal
+from functools import partial
+
+import aiohttp
+import aioredis
from tornado import web
+
from tbot import config, db
from tbot.web import handlers
from tbot.web.io_sighandler import sig_handler
+
def App():
return web.Application(
[
@@ -72,6 +75,7 @@ def App():
(r'/api/twitch/channels/([0-9]+)/check-extra-auth', handlers.api.twitch.check_extra_auth.Handler),
(r'/api/twitch/channels/([0-9]+)/commercial', handlers.api.twitch.commercial.Handler),
(r'/api/twitch/channels/([0-9]+)/self-subs', handlers.api.twitch.self_subs.Handler),
+ (r'/api/live-chat/([0-9]+)', handlers.api.live_chat.LiveChatHandler),
(r'/api/rtmp-auth', handlers.api.rtmp_auth.Handler),
(r'/api/srt-auth', handlers.api.srt_auth.Handler),
diff --git a/tbot/web/handlers/api/__init__.py b/tbot/web/handlers/api/__init__.py
index b53e238..1b921ae 100644
--- a/tbot/web/handlers/api/__init__.py
+++ b/tbot/web/handlers/api/__init__.py
@@ -1,5 +1,12 @@
from . import (
- twitch,
- rtmp_auth,
- srt_auth,
-)
\ No newline at end of file
+ live_chat as live_chat,
+)
+from . import (
+ rtmp_auth as rtmp_auth,
+)
+from . import (
+ srt_auth as srt_auth,
+)
+from . import (
+ twitch as twitch,
+)
diff --git a/tbot/web/handlers/api/live_chat.py b/tbot/web/handlers/api/live_chat.py
new file mode 100644
index 0000000..1d198e2
--- /dev/null
+++ b/tbot/web/handlers/api/live_chat.py
@@ -0,0 +1,64 @@
+from tornado import ioloop, websocket
+
+from tbot import logger
+
+
+class LiveChatHandler(websocket.WebSocketHandler):
+ clients_by_channel = {}
+ clients_to_channels = {}
+ redis_channels = {}
+
+ async def open(self, channel_id: str):
+ if channel_id not in LiveChatHandler.clients_by_channel:
+ LiveChatHandler.clients_by_channel[channel_id] = set()
+ LiveChatHandler.clients_by_channel[channel_id].add(self)
+
+ if self not in LiveChatHandler.clients_to_channels:
+ LiveChatHandler.clients_to_channels[self] = set()
+ LiveChatHandler.clients_to_channels[self].add(channel_id)
+
+ if channel_id not in LiveChatHandler.redis_channels:
+ ioloop.IOLoop.current().add_callback(self.listen_to_redis, channel_id)
+
+ async def listen_to_redis(self, channel_id: str):
+ while True:
+ try:
+ LiveChatHandler.redis_channels[
+ channel_id
+ ] = await self.application.redis.subscribe(
+ f'tbot:live_chat:{channel_id}'
+ )
+ sub = LiveChatHandler.redis_channels[channel_id][0]
+ while await sub.wait_message():
+ try:
+ message = await sub.get()
+ for client in LiveChatHandler.clients_by_channel.get(
+ channel_id, set()
+ ):
+ client.write_message(message.decode('utf-8'))
+ except websocket.WebSocketClosedError:
+ self.on_close()
+ except Exception as e:
+ logger.exception(e)
+ except Exception as e:
+ logger.exception(e)
+
+ async def on_message(self, message):
+ pass
+
+ def on_close(self):
+ async def close():
+ for channel_id in LiveChatHandler.clients_to_channels.get(self, set()):
+ if channel_id in LiveChatHandler.clients_by_channel:
+ LiveChatHandler.clients_by_channel[channel_id].remove(self)
+ if not LiveChatHandler.clients_by_channel[channel_id]:
+ await self.application.redis.unsubscribe(
+ f'tbot:live_chat:{channel_id}'
+ )
+ del LiveChatHandler.clients_by_channel[channel_id]
+ del LiveChatHandler.redis_channels[channel_id]
+
+ if self in LiveChatHandler.clients_to_channels:
+ del LiveChatHandler.clients_to_channels[self]
+
+ ioloop.IOLoop.current().add_callback(close)
diff --git a/tbot/web/ui/index.jsx b/tbot/web/ui/index.jsx
index 998e600..ab1e527 100644
--- a/tbot/web/ui/index.jsx
+++ b/tbot/web/ui/index.jsx
@@ -1,29 +1,41 @@
-import ReactDOM from 'react-dom';
-import {BrowserRouter, Switch, Route} from "react-router-dom";
+import ReactDOM from "react-dom";
+import { BrowserRouter, Switch, Route } from "react-router-dom";
-import Front from 'tbot/front'
+import Front from "tbot/front";
+import LiveChat from "tbot/live_chat";
-import TwitchLogviewer from 'tbot/twitch/logviewer';
-import TwitchLogViewerSelectChannel from 'tbot/twitch/logviewer/selectchannel';
+import TwitchLogviewer from "tbot/twitch/logviewer";
+import TwitchLogViewerSelectChannel from "tbot/twitch/logviewer/selectchannel";
-import TwitchDashboard from 'tbot/twitch/dashboard'
+import TwitchDashboard from "tbot/twitch/dashboard";
-import TwitchPublic from 'tbot/twitch/public'
+import TwitchPublic from "tbot/twitch/public";
-import './index.scss';
+import "./index.scss";
-ReactDOM.render((
-