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(( - - - +ReactDOM.render( + + + - - + - - - - - - -), document.getElementById('root')); \ No newline at end of file + + + + + + + + + , + document.getElementById("root") +); diff --git a/tbot/web/ui/live_chat/components/chat.jsx b/tbot/web/ui/live_chat/components/chat.jsx new file mode 100644 index 0000000..2d7bd2b --- /dev/null +++ b/tbot/web/ui/live_chat/components/chat.jsx @@ -0,0 +1,60 @@ +import React, { useState, useEffect, useRef } from "react"; +import useWebSocket, { ReadyState } from "react-use-websocket"; +import "./chat.scss"; + +export function Chat({ channelId }) { + const { + sendMessage, + sendJsonMessage, + lastMessage, + lastJsonMessage, + readyState, + getWebSocket, + } = useWebSocket(`/api/live-chat/${channelId}`, { + onOpen: () => console.log("opened"), + shouldReconnect: (closeEvent) => true, + }); + const [messageHistory, setMessageHistory] = useState([]); + const messagesEndRef = useRef(null); + + useEffect(() => { + if (lastJsonMessage !== null) { + setMessageHistory((prevMessages) => { + const updatedMessages = [...prevMessages, lastJsonMessage]; + return updatedMessages.slice(-500); + }); + } + }, [lastJsonMessage]); + + useEffect(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: "instant" }); + } + }, [messageHistory]); + console.log(lastJsonMessage); + return ( +
+
+ {messageHistory.map((msg, index) => ( +
+ {providerShort(msg.provider)} + + {msg.user}: + {" "} + {msg.message} +
+ ))} +
+
+
+ ); +} + +function providerShort(provider) { + switch (provider) { + case "twitch": + return T; + case "youtube": + return Y; + } +} diff --git a/tbot/web/ui/live_chat/components/chat.scss b/tbot/web/ui/live_chat/components/chat.scss new file mode 100644 index 0000000..e96b960 --- /dev/null +++ b/tbot/web/ui/live_chat/components/chat.scss @@ -0,0 +1,55 @@ +.chat-container { + width: 420px; + padding: 20px; + background-color: #18181b; + color: #e0e0e0; + border: 1px solid #2d2d2d; + font-family: Arial, sans-serif; + height: 100vh; + font-size: 13px; +} + +.channel-name { + font-size: 1.5rem; + color: #9147ff; + text-align: center; + margin-bottom: 15px; +} + +.messages { + height: 100%; + overflow-y: auto; + background-color: #1f1f23; + padding: 10px; +} + +.message { + padding: 2px 0; + display: flex; + align-items: baseline; +} + +.username { + font-weight: 700; + margin-right: 5px; + color: #ffa600; +} + +.text { + color: #ffffff; + word-wrap: break-word; + line-height: 1.4; +} + +.provider { + font-weight: bold; + margin-right: 0.5rem; +} + +.twitch { + color: #9147ff; +} + +.youtube { + color: #ff0000; +} \ No newline at end of file diff --git a/tbot/web/ui/live_chat/index.jsx b/tbot/web/ui/live_chat/index.jsx new file mode 100644 index 0000000..74dfe9d --- /dev/null +++ b/tbot/web/ui/live_chat/index.jsx @@ -0,0 +1,6 @@ +import { Chat } from "./components/chat"; + +export default function Component(props) { + const channelId = props.match.params.channelId; + return ; +}