commit 61435c69e73d544589261c6fd7b7ecf243a4499b Author: Deko Date: Fri Mar 3 20:38:17 2023 +0100 Initial commit 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 0000000..659fe9c Binary files /dev/null and b/app/__pycache__/discord_client.cpython-311.pyc differ diff --git a/app/__pycache__/twitch_client.cpython-311.pyc b/app/__pycache__/twitch_client.cpython-311.pyc new file mode 100644 index 0000000..530ab0b Binary files /dev/null and b/app/__pycache__/twitch_client.cpython-311.pyc differ 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