From 61435c69e73d544589261c6fd7b7ecf243a4499b Mon Sep 17 00:00:00 2001 From: Deko Date: Fri, 3 Mar 2023 20:38:17 +0100 Subject: [PATCH] Initial commit --- .env.example | 4 + .gitignore | 1 + .idea/.gitignore | 3 + .idea/MelouGoesLive.iml | 8 + .idea/codeStyles/codeStyleConfig.xml | 5 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 4 + .idea/modules.xml | 8 + Dockerfile | 12 ++ README.md | 58 ++++++ app/__init__.py | 0 .../discord_client.cpython-311.pyc | Bin 0 -> 4383 bytes app/__pycache__/twitch_client.cpython-311.pyc | Bin 0 -> 8651 bytes app/discord_client.py | 123 +++++++++++ app/main.py | 79 +++++++ app/twitch_client.py | 180 ++++++++++++++++ poetry.lock | 195 ++++++++++++++++++ pyproject.toml | 21 ++ 18 files changed, 707 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/MelouGoesLive.iml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/__pycache__/discord_client.cpython-311.pyc create mode 100644 app/__pycache__/twitch_client.cpython-311.pyc create mode 100644 app/discord_client.py create mode 100644 app/main.py create mode 100644 app/twitch_client.py create mode 100644 poetry.lock create mode 100644 pyproject.toml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..894e17f --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +STREAMER_NAME="example" +DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/..." +TWITCH_CLIENT_ID="" +TWITCH_CLIENT_SECRET="" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/MelouGoesLive.iml b/.idea/MelouGoesLive.iml new file mode 100644 index 0000000..d0876a7 --- /dev/null +++ b/.idea/MelouGoesLive.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..a55e7a1 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..e1fcb0e --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..b8782a2 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7cbb09c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11.2 + +COPY ./ / +WORKDIR / +RUN apt-get update +RUN apt-get -y dist-upgrade +RUN apt-get -y install python3-pip curl +RUN curl -sSL https://install.python-poetry.org | python3 - +ENV PATH=/root/.local/bin:$PATH +RUN poetry install + +CMD ["poetry", "run", "python", "app/main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..607d29f --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Discord Twitch Live Notification + +This is a python project to send a Discord webhook with a self-updating webhook +when a specified streamer goes live on Twitch. +Checks and updates exactly once every half minute. + +The motivation behind this project is that requiring discord.js or the twitch api library is too much in my opinion. +Doing this requires 7 API calls, including the really basic authentication in twitch's case. + +All options to run this require environment variables. You can see them in [this file](.env). + +## Running locally + +The first option to run the project is to run it locally. +You may install the dependencies through pip, however it is recommended to install them with the project default, [poetry](https://python-poetry.org). + +### Install prerequisites + +- Python 3.11.2 + +Clone the repository: +```commandline +git clone https://github.com/Gadsee/Discord-Twitch-Live-Notifications.git +``` + +Install poetry. (Taken from the [official documentation](https://python-poetry.org/docs/).) + +Debian-based linux distributions: +```bash +sudo apt-get install python3-pip curl +curl -sSL https://install.python-poetry.org | python3 - +``` + +Windows: +```powershell +(Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py - +``` + +### Install dependencies + +```commandline +poetry env use 3.11.2 +poetry install +``` + +### Run the app + +Replace `source .env` with your OS' appropriate way of loading environment variables. + +```bash +poetry shell +source .env +python app/main.py +``` + +## Running in Docker + +The second option is to run the project's docker image. \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/__pycache__/discord_client.cpython-311.pyc b/app/__pycache__/discord_client.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..659fe9c3c762fe72eee22a937bc7d6503b846212 GIT binary patch literal 4383 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 RAn(2D6I%alpH?dF{{d*CXPy86 literal 0 HcmV?d00001 diff --git a/app/discord_client.py b/app/discord_client.py new file mode 100644 index 0000000..723da3a --- /dev/null +++ b/app/discord_client.py @@ -0,0 +1,123 @@ +import os + +import requests +from loguru import logger + +from twitch_client import StreamInformation + + +class DiscordClient: + _notification_msg_id: str = "" + + def __init__(self): + self._webhook_url = os.environ["DISCORD_WEBHOOK_URL"] + + def send_information_to_discord( + self, stream: StreamInformation, profile_image: str + ) -> str: + logger.info("Sending a message with an embed to the webhook...") + streamer_url = f"https://www.twitch.tv/{stream.user_login}/" + response = requests.post( + url=f"{self._webhook_url}?wait=true", + json={ + "username": "Oak Tree", + "avatar_url": "https://i.imgur.com/DBOuwjx.png", + "content": "@everyone", + "embeds": [ + { + "title": stream.title, + "color": 8388863, + "timestamp": stream.started_at, + "url": streamer_url, + "author": { + "name": stream.user_name, + "url": streamer_url, + "icon_url": profile_image, + }, + "image": {"url": stream.thumbnail_url}, + "fields": [ + { + "name": "Game", + "value": stream.game_name, + "inline": True, + }, + { + "name": "Viewers", + "value": stream.viewer_count, + "inline": True, + }, + ], + } + ], + }, + ) + + response.raise_for_status() + + self._notification_msg_id = response.json()["id"] + logger.info("Stream information sent with ping to Discord.") + return self._notification_msg_id + + def update_information_on_discord( + self, stream: StreamInformation, profile_image: str + ) -> None: + logger.info("Updating stream information on Discord...") + streamer_url = f"https://www.twitch.tv/{stream.user_login}/" + response = requests.patch( + url=f"{self._webhook_url}/messages/{self._notification_msg_id}", + json={ + "embeds": [ + { + "title": stream.title, + "color": 8388863, + "timestamp": stream.started_at, + "url": streamer_url, + "author": { + "name": f"{stream.user_name}", + "url": streamer_url, + "icon_url": profile_image, + }, + "image": {"url": stream.thumbnail_url}, + "fields": [ + { + "name": "Game", + "value": stream.game_name, + "inline": True, + }, + { + "name": "Viewers", + "value": stream.viewer_count, + "inline": True, + }, + ], + } + ], + }, + ) + response.raise_for_status() + logger.info("Message embed content updated.") + + def finalize_information_on_discord( + self, streamer_name, vod_url: str | None + ) -> None: + logger.info("Finalizing stream information on Discord...") + if not self._notification_msg_id: + logger.info("Message ID not set, nothing to finalize.") + return + + if not vod_url: + vod_url = "None available. Please contact the developer." + + response = requests.patch( + url=f"{self._webhook_url}/messages/{self._notification_msg_id}", + json={ + "username": "Oak Tree", + "avatar_url": "https://i.imgur.com/DBOuwjx.png", + "content": ( + f"{streamer_name} stopped the stream. VOD: \n{vod_url}" + ), + "embeds": [], + }, + ) + response.raise_for_status() + logger.info("Message updated with VOD.") diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..b5fc39f --- /dev/null +++ b/app/main.py @@ -0,0 +1,79 @@ +import os +import time + +from loguru import logger +from requests import HTTPError + +from discord_client import DiscordClient +from twitch_client import TwitchClient, StreamInformation + + +class Main: + is_live: bool = False + _previous_stream: StreamInformation = None + + def __init__(self): + self.twitch_client = TwitchClient(streamer=os.environ["STREAMER_NAME"]) + self.twitch_client.update_access_token() + self.profile_image = self.twitch_client.get_streamer_profile_picture() + self.discord_client = DiscordClient() + + def update_status(self): + try: + stream = self.twitch_client.get_stream() + + if not stream: + if self.is_live: + logger.info("Streamer went offline.") + self.is_live = False + self.discord_client.finalize_information_on_discord( + streamer_name=self._previous_stream.user_name, + vod_url=self.twitch_client.get_vod( + user_id=self._previous_stream.user_id + ), + ) + return + + if not self.is_live: + logger.info("Streamer went live.") + self.discord_client.send_information_to_discord( + stream=stream, profile_image=self.profile_image + ) + self.is_live = True + else: + logger.info("Streamer still live.") + self.discord_client.update_information_on_discord( + stream=stream, profile_image=self.profile_image + ) + + self._previous_stream = stream + except HTTPError as e: + logger.exception(e) + + def interrupt(self): + if not self.is_live: + return + + self.is_live = False + self.discord_client.finalize_information_on_discord( + streamer_name=self._previous_stream.user_login, + vod_url=self.twitch_client.get_vod( + user_id=self._previous_stream.user_id + ), + ) + + +if __name__ == "__main__": + logger.info("Initiating main...") + start_time = time.time() + main = Main() + + logger.info("Set-up looks correct, starting main loop.") + try: + while True: + main.update_status() + time.sleep(30.0 - ((time.time() - start_time) % 30.0)) + except (SystemExit, KeyboardInterrupt): + logger.info("Caught wish to exit, interrupting and re-raising.") + main.interrupt() + raise diff --git a/app/twitch_client.py b/app/twitch_client.py new file mode 100644 index 0000000..700bf2d --- /dev/null +++ b/app/twitch_client.py @@ -0,0 +1,180 @@ +import os +import random +from dataclasses import dataclass + +import requests +from loguru import logger +from requests import HTTPError + + +@dataclass +class CachePrevent: + calls: int = 0 + random_number: int = 0 + five_minute_update_modulo: int = 10 + + def prevent_cache_on_url(self, url: str) -> str: + self.calls += 1 + if self.calls % self.five_minute_update_modulo == 0: + self.random_number = random.randint(0, 999999) + + return f"{url}?{self.random_number}" + + +@dataclass +class StreamInformation: + user_id: str + user_name: str + user_login: str + title: str + game_name: str + viewer_count: int + started_at: str + _thumbnail_url: str + + @property + def thumbnail_url(self): + return ( + self._thumbnail_url + .replace("{width}", "1280") + .replace("{height}", "720") + ) + + +class TwitchClient: + _access_token: str = "" + + def __init__(self, streamer: str): + self.streamer = streamer + self._client_id = os.environ["TWITCH_CLIENT_ID"] + self._client_secret = os.environ["TWITCH_CLIENT_SECRET"] + self._cache_prevent = CachePrevent() + + def update_access_token(self) -> None: + logger.info("Updating twitch access token...") + response = requests.post( + url="https://id.twitch.tv/oauth2/token", + headers={"Content-Type": "application/x-www-form-url-encoded"}, + params={ + "client_id": self._client_id, + "client_secret": self._client_secret, + "grant_type": "client_credentials", + }, + ) + response.raise_for_status() + + self._access_token = response.json()["access_token"] + logger.info("Updating twitch access token done.") + + def _update_access_token_wrapper(self) -> bool: + try: + self.update_access_token() + except HTTPError as e: + logger.error("API call to update twitch access token failed.") + logger.exception(e) + return False + except KeyError: + logger.error("Access token was not in auth response.") + return False + return True + + def get_streamer_profile_picture( + self, is_retry: bool = False + ) -> str | None: + response = requests.get( + url="https://api.twitch.tv/helix/users", + headers={ + "Client-Id": self._client_id, + "Authorization": f"Bearer {self._access_token}", + }, + params={"login": self.streamer}, + ) + + if response.status_code == 401: + logger.info("Getting streamer returned an auth issue.") + + if is_retry: + logger.error("Auth failed twice, aborting.") + return None + + if not self._update_access_token_wrapper(): + return None + + return self.get_streamer_profile_picture(is_retry=True) + + response.raise_for_status() + + return response.json()["data"][0]["profile_image_url"] + + def get_stream(self, is_retry: bool = False) -> StreamInformation | None: + response = requests.get( + url="https://api.twitch.tv/helix/streams", + headers={ + "Client-Id": self._client_id, + "Authorization": f"Bearer {self._access_token}", + }, + params={"user_login": self.streamer}, + ) + + if response.status_code == 401: + logger.info("Getting streams returned an auth issue.") + + if is_retry: + logger.error("Auth failed twice, aborting.") + return None + + if not self._update_access_token_wrapper(): + return None + + return self.get_stream(is_retry=True) + + response.raise_for_status() + + streams = response.json()["data"] + if len(streams) == 0: + return None + + stream_data = streams[0] + return StreamInformation( + user_id=stream_data.get("user_id"), + user_name=stream_data.get("user_name"), + user_login=stream_data.get("user_login"), + title=stream_data.get("title"), + game_name=stream_data.get("game_name"), + viewer_count=stream_data.get("viewer_count"), + started_at=stream_data.get("started_at"), + _thumbnail_url=self._cache_prevent.prevent_cache_on_url( + url=stream_data.get("thumbnail_url"), + ) + ) + + def get_vod(self, user_id: str, is_retry: bool = False) -> str | None: + response = requests.get( + url="https://api.twitch.tv/helix/videos", + headers={ + "Client-Id": self._client_id, + "Authorization": f"Bearer {self._access_token}", + }, + params={"user_id": user_id}, + ) + + if response.status_code == 401: + logger.info("Getting vod returned an auth issue.") + + if is_retry: + logger.error("Auth failed twice, aborting.") + return None + + if not self._update_access_token_wrapper(): + return None + + return self.get_vod(user_id=user_id, is_retry=True) + + response.raise_for_status() + + vods = response.json()["data"] + if len(vods) == 0: + return None + + vod_data = vods[0] + return vod_data.get("url") diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..c0708f5 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,195 @@ +[[package]] +name = "black" +version = "23.1.0" +description = "The uncompromising code formatter." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2022.12.7" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "charset-normalizer" +version = "3.0.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "isort" +version = "5.12.0" +description = "A Python utility / library to sort Python imports." +category = "main" +optional = false +python-versions = ">=3.8.0" + +[package.extras] +colors = ["colorama (>=0.4.3)"] +requirements-deprecated-finder = ["pip-api", "pipreqs"] +pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] +plugins = ["setuptools"] + +[[package]] +name = "loguru" +version = "0.6.0" +description = "Python logging made (stupidly) simple" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["colorama (>=0.3.4)", "docutils (==0.16)", "flake8 (>=3.7.7)", "tox (>=3.9.0)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "black (>=19.10b0)", "isort (>=5.1.1)", "Sphinx (>=4.1.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "packaging" +version = "23.0" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "pathspec" +version = "0.11.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "platformdirs" +version = "3.0.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)", "sphinx (>=6.1.3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest (>=7.2.1)"] + +[[package]] +name = "requests" +version = "2.28.2" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7, <4" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "urllib3" +version = "1.26.14" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[package.extras] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "win32-setctime" +version = "1.1.0" +description = "A small Python utility to set file creation time on Windows" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.11" +content-hash = "2cd384690a48bd802789a049147162c975c512098cc42b7716ba54b06f448092" + +[metadata.files] +black = [] +certifi = [] +charset-normalizer = [] +click = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] +colorama = [] +idna = [] +isort = [] +loguru = [] +mypy-extensions = [] +packaging = [] +pathspec = [] +platformdirs = [] +requests = [] +urllib3 = [] +win32-setctime = [] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c3eb69b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[tool.poetry] +name = "melougoeslive" +version = "0.1.0" +description = "" +authors = ["Deko "] + +[tool.poetry.dependencies] +python = "^3.11" +requests = "^2.28.2" +loguru = "^0.6.0" +black = "^23.1.0" +isort = "^5.12.0" + +[tool.poetry.dev-dependencies] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 80