diff --git a/haugebot/asgi.py b/haugebot/asgi.py index abb3541ea3f4de2dd0b3ebbca312a74d75ad794f..3d887d50d62ee1c184abd6f82595195fbe146d47 100644 --- a/haugebot/asgi.py +++ b/haugebot/asgi.py @@ -9,8 +9,19 @@ https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ import os +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter from django.core.asgi import get_asgi_application +import haugebot.routing + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'haugebot.settings') -application = get_asgi_application() +application = ProtocolTypeRouter({ + "http": get_asgi_application(), + "websocket": AuthMiddlewareStack( + URLRouter( + haugebot.routing.websocket_urlpatterns + ) + ) +}) diff --git a/haugebot/consumers.py b/haugebot/consumers.py new file mode 100644 index 0000000000000000000000000000000000000000..eaa390eea21f4dff4e67e675c905b699539f2cbe --- /dev/null +++ b/haugebot/consumers.py @@ -0,0 +1,102 @@ +import json +import os + +from django.contrib.sessions.models import Session +from asgiref.sync import async_to_sync +from channels.generic.websocket import WebsocketConsumer +from wordcloud import WordCloud + +secret = os.getenv("WORDCLOUD_SECRET") +words = {} +permitted = [] +uuid = None + + +def make_image(uuid, words, permitted): + permitted_words = {word: qty for (word, qty) in words.items() if word in permitted} + if len(permitted_words) > 0: + wc = WordCloud(background_color="black", width=1920, height=1080, min_font_size=1, relative_scaling=1) + wc.generate_from_frequencies(permitted_words) + + wc.to_file(f"media/{uuid}.png") + + +def verify_session_key(session_key): + return len(list(Session.objects.filter(session_key=session_key))) > 0 + + +class WordCloudConsumer(WebsocketConsumer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def connect(self): + async_to_sync(self.channel_layer.group_add)( + "wordcloud", + self.channel_name + ) + + self.accept() + + def disconnect(self, code): + async_to_sync(self.channel_layer.group_discard)( + "wordcloud", + self.channel_name + ) + + def receive(self, text_data=None, bytes_data=None): + data = json.loads(text_data) + message_type = data.get("type") + + if message_type == "word_update": + self.word_update(data) + elif message_type == "permit": + self.permit(data) + elif message_type == "deny": + self.deny(data) + + async_to_sync(self.channel_layer.group_send)( + "wordcloud", + { + "type": "broadcast", + "data": data + } + ) + + def word_update(self, data): + global uuid, words, permitted + + if data.get("secret") != secret: + return + + if uuid != data.get("uuid"): + uuid = data.get("uuid") + words = {} + permitted = [] + + if words != data.get("words"): + words = data.get("words") + make_image(uuid, words, permitted) + + def permit(self, data): + if not verify_session_key(data.get("session_key")): + return + word = data.get("word") + if word not in permitted: + permitted.append(word) + make_image(uuid, words, permitted) + + def deny(self, data): + if not verify_session_key(data.get("session_key")): + return + word = data.get("word") + if word in permitted: + permitted.remove(word) + make_image(uuid, words, permitted) + + def broadcast(self, event): + message = { + "uuid": uuid, + "words": words, + "permitted": permitted + } + self.send(text_data=json.dumps(message)) diff --git a/haugebot/routing.py b/haugebot/routing.py new file mode 100644 index 0000000000000000000000000000000000000000..558cff4c7dc6d93ce05a0f5de61759232a3e87da --- /dev/null +++ b/haugebot/routing.py @@ -0,0 +1,7 @@ +from django.urls import re_path + +from . import consumers + +websocket_urlpatterns = [ + re_path(r'ws/wordcloud/$', consumers.WordCloudConsumer.as_asgi()), +] \ No newline at end of file diff --git a/haugebot/settings.py b/haugebot/settings.py index 0270863c0c20e4bc4174ab13bb9b7a29b12098ea..92ecbb4898af3aa0149057eb494ae4dd51d40bd4 100644 --- a/haugebot/settings.py +++ b/haugebot/settings.py @@ -44,6 +44,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'channels', 'haugebot_web', ] @@ -76,7 +77,15 @@ TEMPLATES = [ }, ] -WSGI_APPLICATION = 'haugebot.wsgi.application' +ASGI_APPLICATION = 'haugebot.asgi.application' +CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels_redis.core.RedisChannelLayer', + 'CONFIG': { + 'hosts': [('127.0.0.1', 6379)], + } + }, +} # Database # https://docs.djangoproject.com/en/3.1/ref/settings/#databases @@ -124,3 +133,6 @@ USE_TZ = True STATIC_URL = '/static/' STATIC_ROOT = 'static/' + +MEDIA_URL = '/media/' +MEDIA_ROOT = 'media/' diff --git a/haugebot/urls.py b/haugebot/urls.py index 971b78fc9843ad7affc843155c3125f26fe7ac66..225e361881688573ffe24f5d14405bcb1d8abe16 100644 --- a/haugebot/urls.py +++ b/haugebot/urls.py @@ -15,6 +15,8 @@ Including another URLconf """ from django.contrib import admin from django.urls import path +from django.conf import settings +from django.conf.urls.static import static from haugebot_web import views @@ -26,4 +28,10 @@ urlpatterns = [ path('login/redirect/', views.login_redirect, name="login_redirect"), path('wusstest_du_schon/', views.wusstest_du_schon, name="wusstest_du_schon"), path('wusstest_du_schon/remove/<int:id>', views.wusstest_du_schon_remove, name="wusstest_du_schon_remove"), + path('wordcloud/', views.wordcloud, name="wordcloud"), + path('wordcloud/live/<str:id>', views.wordcloud_live, name="wordcloud_live") ] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, + document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/haugebot_twitch/haugebot.py b/haugebot_twitch/haugebot.py index 26b59e1f43ccd9ea26de41174392e36095e605e4..b5fb0ece447c46fa06594ff86c9b219574aaf5a6 100644 --- a/haugebot_twitch/haugebot.py +++ b/haugebot_twitch/haugebot.py @@ -8,6 +8,7 @@ from twitchio import Channel, Message from vote_cog import VoteCog from wusstest_du_schon import WusstestDuSchon +from wordcloud import Wordcloud class HaugeBot(Bot, ABC): @@ -23,6 +24,7 @@ class HaugeBot(Bot, ABC): client_secret=self.CLIENT_SECRET) self.add_cog(VoteCog(self)) self.add_cog(WusstestDuSchon(self)) + self.add_cog(Wordcloud(self)) @staticmethod async def send_me(ctx, content): diff --git a/haugebot_twitch/vote_cog.py b/haugebot_twitch/vote_cog.py index 8fee59301eafbcb8dc037ed29433d82a647b3c73..9a3d7ecde8a943a087ed745d02745a0e3022da1b 100644 --- a/haugebot_twitch/vote_cog.py +++ b/haugebot_twitch/vote_cog.py @@ -1,4 +1,3 @@ -import asyncio import os from datetime import datetime, timedelta diff --git a/haugebot_twitch/wordcloud.py b/haugebot_twitch/wordcloud.py new file mode 100644 index 0000000000000000000000000000000000000000..5bd037e7c52258c5057b3794b917b9ba6f10517a --- /dev/null +++ b/haugebot_twitch/wordcloud.py @@ -0,0 +1,70 @@ +import json +import os +import uuid +from datetime import datetime + +from twitchio.ext import commands, routines +from websockets import connect + + +def get_local_timezone(): + return datetime.now().astimezone().tzinfo + + +class Wordcloud(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.running = False + self.words = {} + self.id = "" + self.start_date = None + self.ws_url = os.getenv("WORDCLOUD_WS_URL") + self.secret = os.getenv("WORDCLOUD_SECRET") + self.ws = None + self.wordcloud_routine.start(self) + + @routines.routine(seconds=2) + async def wordcloud_routine(self): + try: + if self.running: + if not self.ws or self.ws.closed: + self.ws = await connect(self.ws_url) + + if self.ws: + js = { + "type": "word_update", + "secret": self.secret, + "uuid": str(self.id), + "start_date": self.start_date, + "words": self.sum_words() + } + print(js) + await self.ws.send(json.dumps(js)) + if not self.running: + if self.ws and not self.ws.closed: + await self.ws.close() + except Exception: + pass + + @commands.command(name="wcloud") + async def cmd_wcloud(self, ctx: commands.Context, phrase: str): + if phrase in ["start", "stop"] and ctx.author.is_mod: + if phrase == "start": + self.running = True + self.words = {} + self.id = uuid.uuid4() + self.start_date = datetime.now(tz=get_local_timezone()).isoformat(timespec="seconds") + else: + self.running = False + else: + self.words[ctx.author.name] = phrase + + def sum_words(self): + words = {} + for user, word in self.words.items(): + if count := words.get(word): + words[word] = count + 1 + else: + words[word] = 1 + + return words diff --git a/haugebot_web/models.py b/haugebot_web/models.py index 35ba3890ce261b90c885389b9e04acdd9ed9d8f1..8cd484ac868a60c4c663a9b849235a7d21fb7de7 100644 --- a/haugebot_web/models.py +++ b/haugebot_web/models.py @@ -45,11 +45,20 @@ class TwitchUser(models.Model): @property def is_authenticated(self): - broadcaster_id = int(os.getenv("BROADCASTER_ID")) - if self.id == broadcaster_id or self.admin: - return True + return self.is_broadcaster or self.is_admin or self.is_mod + + @property + def is_broadcaster(self): + return self.id == int(os.getenv("BROADCASTER_ID")) + + @property + def is_mod(self): + return self.admin + + @property + def is_mod(self): try: - broadcaster = TwitchUser.objects.get(pk=broadcaster_id) + broadcaster = TwitchUser.objects.get(pk=int(os.getenv("BROADCASTER_ID"))) return twitch_api.is_mod(self, broadcaster) except TwitchUser.DoesNotExist: return False diff --git a/haugebot_web/static/css/styles.css b/haugebot_web/static/css/styles.css index 9f1bc763ca1552b1c4bfee5a869c589f3732dcff..6c7bef2a481adcf1224cf27df83431a5a03f6341 100644 --- a/haugebot_web/static/css/styles.css +++ b/haugebot_web/static/css/styles.css @@ -37,6 +37,10 @@ background: var(--haugepinkdark) !important; } +.w3-hover-haugepink-light:hover { + background: var(--haugepink) !important; +} + .w3-hover-haugepink.active { background: var(--haugepink) !important; } diff --git a/haugebot_web/templates/layout.html b/haugebot_web/templates/layout.html index 841eb0a28f55fb7d5bb5393875370cf9fcfaea80..34fcd7d85874404a800b8e91e185dc8d54d2b50e 100644 --- a/haugebot_web/templates/layout.html +++ b/haugebot_web/templates/layout.html @@ -19,6 +19,8 @@ <a href="{% url 'wusstest_du_schon' %}" class="w3-bar-item w3-button {% if title == "Wusstest du Schon?" %}w3-light-gray{% endif %}">Wusstest du Schon?</a> + <a href="{% url 'wordcloud' %}" + class="w3-bar-item w3-button {% if title == "Wordcloud" %}w3-light-gray{% endif %}">Wordcloud</a> <a href="{% url 'logout' %}" class="w3-bar-item w3-button">Logout</a> {% else %} <a href="{% url 'login' %}" class="w3-bar-item w3-button">Login</a> diff --git a/haugebot_web/templates/live-wordcloud.html b/haugebot_web/templates/live-wordcloud.html new file mode 100644 index 0000000000000000000000000000000000000000..18906fdc076cdb98c7fe3be5b927b6bc6c19d0b1 --- /dev/null +++ b/haugebot_web/templates/live-wordcloud.html @@ -0,0 +1,25 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Live Wordcloud</title> +</head> +<body style="margin: 0; padding: 0; background: black"> +<img style="width: 100vw; height: 100vh; object-fit: contain; display: none;" src="#" id="wordcloud_img"/> + +</body> +<script> + ws = new WebSocket("{{ ws_url }}"); + + ws.onmessage = function (event) { + let data = JSON.parse(event.data); + let img = document.querySelector("#wordcloud_img") + if (Object.keys(data.permitted).length == 0) { + img.style.display = "none"; + } else { + img.style.display = "block"; + img.setAttribute("src", "/media/" + data.uuid + ".png?t=" + new Date().getTime()) + } + } +</script> +</html> \ No newline at end of file diff --git a/haugebot_web/templates/wordcloud.html b/haugebot_web/templates/wordcloud.html new file mode 100644 index 0000000000000000000000000000000000000000..9307230ffb416e88d3df247b2b84bf70124914d8 --- /dev/null +++ b/haugebot_web/templates/wordcloud.html @@ -0,0 +1,89 @@ +{% extends 'layout.html' %} + +{% block content %} + <div class="w3-row w3-display-container w3-bottombar"> + <a href="#"> + <div id="tablink_filter" + class="w3-col s3 l3 m3 tablink w3-hover-haugepink w3-padding active"> + Filter + </div> + </a> + <!-- + <a href="#"> + <div id="tablink_all" + class="w3-col s3 l3 m3 tablink w3-hover-haugepink w3-padding"> + Alle Wordclouds + </div> + </a> + --> + </div> + + <div class="w3-container w3-card-4 w3-white" id="filter"> + {% if user.is_broadcaster %} + <h3>OBS Browser Source URL: {{ embed_link }}</h3> + <hr> + {% endif %} + <h3>Freigegeben</h3> + <ul class="w3-ul" id="permitted"> + </ul> + <hr/> + <h3>Noch nicht freigegeben</h3> + <ul class="w3-ul" id="not-permitted"> + </ul> + </div> + <div class="w3-container form" id="all" + style="display:none;"> + <form method="post"> + <input type="hidden" id="id-active" value="{{ form.name }}" name="form-active"/> + {% csrf_token %} + {% if form.display == "list" %} + {% if form.type == "form" %} + {% include 'list_form.html' %} + {% elif form.type == "formset" %} + {% include 'list_formset.html' %} + {% endif %} + {% elif form.display == "card" %} + {% if form.type == "form" %} + {% include 'card_form.html' %} + {% elif form.type == "formset" %} + {% include 'card_formset.html' %} + {% endif %} + {% endif %} + <input type="submit" value="Add/Save" class="w3-button w3-haugepink w3-block"> + </form> + </div> + <script type="text/javascript"> + ws = new WebSocket("{{ ws_url }}"); + let permitted = document.querySelector("#permitted"); + let notPermitted = document.querySelector("#not-permitted"); + let session_key = "{{ session_key }}"; + + ws.onmessage = function (event) { + data = JSON.parse(event.data); + permitted.querySelectorAll('*').forEach(n => n.remove()); + notPermitted.querySelectorAll('*').forEach(n => n.remove()); + for (word in data.words) { + li = document.createElement("li"); + li.classList.add("w3-hover-haugepink-light"); + li.textContent = word; + + + if (data.permitted.includes(word)) { + li.addEventListener("click", ev => { + word = ev.target.textContent; + message = {"type": "deny", "word": word, "session_key": session_key}; + ws.send(JSON.stringify(message)); + }); + permitted.append(li); + } else { + li.addEventListener("click", ev => { + word = ev.target.textContent; + message = {"type": "permit", "word": word, "session_key": session_key}; + ws.send(JSON.stringify(message)); + }); + notPermitted.append(li); + } + } + } + </script> +{% endblock %} \ No newline at end of file diff --git a/haugebot_web/views.py b/haugebot_web/views.py index da64818a4e4c17bb02306e8b18685a065c6abe80..5d70217897945d77d1872b294fda3b312e967bb8 100644 --- a/haugebot_web/views.py +++ b/haugebot_web/views.py @@ -5,6 +5,7 @@ from django.contrib.auth import authenticate, login as django_login, logout as d from django.contrib.auth.decorators import login_required from django.forms import modelformset_factory from django.shortcuts import render, redirect +from django.urls import reverse from .forms import BaseForm, WusstestDuSchonConfigForm from .models import WusstestDuSchon @@ -58,6 +59,19 @@ def wusstest_du_schon_remove(request, id): return redirect("/wusstest_du_schon") +@login_required(login_url="/login") +def wordcloud(request): + id = os.getenv("DJANGO_WORDCLOUD_LIVE_ID") + embed_link = f"{request.scheme}://{request.headers['Host']}{reverse('wordcloud_live', args=(id,))}" if request.user.is_broadcaster else "" + return render(request, "wordcloud.html", {'title': 'Wordcloud', "ws_url": os.getenv("WORDCLOUD_WS_URL"), + "session_key": request.session.session_key, "embed_link": embed_link}) + + +def wordcloud_live(request, id): + if id == os.getenv("DJANGO_WORDCLOUD_LIVE_ID"): + return render(request, "live-wordcloud.html", {"ws_url": os.getenv("WORDCLOUD_WS_URL")}) + + def login(request): client_id = os.getenv("CLIENT_ID") redirect_uri = os.getenv("REDIRECT_URI") diff --git a/requirements.txt b/requirements.txt index f63958d3e1a0cbd9c11925db7204ab501a864f09..6d04985412b95f16c575d6813b77ebf45021f74e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ aiohttp==3.7.4 -asgiref==3.3.1 +asgiref==3.4.1 async-timeout==3.0.1 attrs==20.3.0 certifi==2020.12.5 chardet==3.0.4 -Django==3.1.12 +Django==3.2.5 idna==2.10 multidict==5.1.0 python-dotenv==0.15.0 @@ -14,6 +14,6 @@ requests==2.25.1 sqlparse==0.4.1 twitchio==2.0.0b7 typing-extensions==3.7.4.3 -websockets==9.1 urllib3==1.26.5 +websockets==9.1 yarl==1.6.3