From a158ab73d161a52dedf18d24e701c58d9d0111a2 Mon Sep 17 00:00:00 2001 From: dnns01 <git@dnns01.de> Date: Wed, 21 Jul 2021 23:58:49 +0200 Subject: [PATCH] Initial creation of wordcloud functionality --- haugebot/asgi.py | 13 ++- haugebot/consumers.py | 102 +++++++++++++++++++++ haugebot/routing.py | 7 ++ haugebot/settings.py | 14 ++- haugebot/urls.py | 8 ++ haugebot_twitch/haugebot.py | 2 + haugebot_twitch/vote_cog.py | 1 - haugebot_twitch/wordcloud.py | 70 ++++++++++++++ haugebot_web/models.py | 17 +++- haugebot_web/static/css/styles.css | 4 + haugebot_web/templates/layout.html | 2 + haugebot_web/templates/live-wordcloud.html | 25 +++++ haugebot_web/templates/wordcloud.html | 89 ++++++++++++++++++ haugebot_web/views.py | 14 +++ requirements.txt | 6 +- 15 files changed, 364 insertions(+), 10 deletions(-) create mode 100644 haugebot/consumers.py create mode 100644 haugebot/routing.py create mode 100644 haugebot_twitch/wordcloud.py create mode 100644 haugebot_web/templates/live-wordcloud.html create mode 100644 haugebot_web/templates/wordcloud.html diff --git a/haugebot/asgi.py b/haugebot/asgi.py index abb3541..3d887d5 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 0000000..eaa390e --- /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 0000000..558cff4 --- /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 0270863..92ecbb4 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 971b78f..225e361 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 26b59e1..b5fb0ec 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 8fee593..9a3d7ec 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 0000000..5bd037e --- /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 35ba389..8cd484a 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 9f1bc76..6c7bef2 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 841eb0a..34fcd7d 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 0000000..18906fd --- /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 0000000..9307230 --- /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 da64818..5d70217 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 f63958d..6d04985 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 -- GitLab