├── twitchAPI ├── py.typed ├── __init__.py ├── object │ ├── __init__.py │ ├── base.py │ └── api.py ├── helper.py ├── chat │ └── middleware.py ├── eventsub │ ├── webhook.py │ ├── __init__.py │ └── websocket.py └── oauth.py ├── docs ├── _static │ ├── logo.ico │ ├── logo.png │ ├── logo-16x16.png │ ├── logo-32x32.png │ ├── css │ │ └── custom.css │ ├── switcher.json │ └── icons │ │ └── pypi-icon.js ├── modules │ ├── twitchAPI.chat.rst │ ├── twitchAPI.type.rst │ ├── twitchAPI.helper.rst │ ├── twitchAPI.oauth.rst │ ├── twitchAPI.object.rst │ ├── twitchAPI.twitch.rst │ ├── twitchAPI.eventsub.rst │ ├── twitchAPI.object.api.rst │ ├── twitchAPI.object.base.rst │ ├── twitchAPI.chat.middleware.rst │ ├── twitchAPI.object.eventsub.rst │ ├── twitchAPI.eventsub.base.rst │ ├── twitchAPI.eventsub.webhook.rst │ └── twitchAPI.eventsub.websocket.rst ├── _templates │ └── autosummary │ │ └── module.rst ├── requirements.txt ├── tutorials.rst ├── Makefile ├── make.bat ├── tutorial │ ├── user-auth-headless.rst │ ├── reuse-user-token.rst │ ├── mocking.rst │ └── chat-use-middleware.rst ├── v4-migration.rst ├── conf.py ├── v3-migration.rst └── index.rst ├── requirements.txt ├── pyproject.toml ├── MANIFEST.in ├── .readthedocs.yaml ├── .gitignore ├── LICENSE.txt ├── setup.cfg ├── setup.py └── README.md /twitchAPI/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /twitchAPI/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (4, 5, 0, '') 2 | 3 | __version__ = '4.5.0' 4 | -------------------------------------------------------------------------------- /docs/_static/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Teekeks/pyTwitchAPI/HEAD/docs/_static/logo.ico -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Teekeks/pyTwitchAPI/HEAD/docs/_static/logo.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=3.9.3 2 | python-dateutil>=2.8.2 3 | typing_extensions 4 | enum-tools 5 | -------------------------------------------------------------------------------- /docs/_static/logo-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Teekeks/pyTwitchAPI/HEAD/docs/_static/logo-16x16.png -------------------------------------------------------------------------------- /docs/_static/logo-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Teekeks/pyTwitchAPI/HEAD/docs/_static/logo-32x32.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | exclude .readthedocs.yaml 2 | exclude .gitignore 3 | exclude requirements.txt 4 | prune venv 5 | prune docs 6 | prune tests 7 | -------------------------------------------------------------------------------- /docs/modules/twitchAPI.chat.rst: -------------------------------------------------------------------------------- 1 | 2 | .. automodule:: twitchAPI.chat 3 | :members: 4 | :undoc-members: 5 | :show-inheritance: 6 | :inherited-members: -------------------------------------------------------------------------------- /docs/modules/twitchAPI.type.rst: -------------------------------------------------------------------------------- 1 | 2 | .. automodule:: twitchAPI.type 3 | :members: 4 | :undoc-members: 5 | :show-inheritance: 6 | :inherited-members: -------------------------------------------------------------------------------- /docs/modules/twitchAPI.helper.rst: -------------------------------------------------------------------------------- 1 | 2 | .. automodule:: twitchAPI.helper 3 | :members: 4 | :undoc-members: 5 | :show-inheritance: 6 | :inherited-members: -------------------------------------------------------------------------------- /docs/modules/twitchAPI.oauth.rst: -------------------------------------------------------------------------------- 1 | 2 | .. automodule:: twitchAPI.oauth 3 | :members: 4 | :undoc-members: 5 | :show-inheritance: 6 | :inherited-members: -------------------------------------------------------------------------------- /docs/modules/twitchAPI.object.rst: -------------------------------------------------------------------------------- 1 | 2 | .. automodule:: twitchAPI.object 3 | :members: 4 | :undoc-members: 5 | :show-inheritance: 6 | :inherited-members: -------------------------------------------------------------------------------- /docs/modules/twitchAPI.twitch.rst: -------------------------------------------------------------------------------- 1 | 2 | .. automodule:: twitchAPI.twitch 3 | :members: 4 | :undoc-members: 5 | :show-inheritance: 6 | :inherited-members: -------------------------------------------------------------------------------- /docs/modules/twitchAPI.eventsub.rst: -------------------------------------------------------------------------------- 1 | 2 | .. automodule:: twitchAPI.eventsub 3 | :members: 4 | :undoc-members: 5 | :show-inheritance: 6 | :inherited-members: -------------------------------------------------------------------------------- /docs/_templates/autosummary/module.rst: -------------------------------------------------------------------------------- 1 | 2 | .. automodule:: {{module}}.{{name}} 3 | :members: 4 | :undoc-members: 5 | :show-inheritance: 6 | :inherited-members: 7 | -------------------------------------------------------------------------------- /docs/modules/twitchAPI.object.api.rst: -------------------------------------------------------------------------------- 1 | 2 | .. automodule:: twitchAPI.object.api 3 | :members: 4 | :undoc-members: 5 | :show-inheritance: 6 | :inherited-members: 7 | -------------------------------------------------------------------------------- /docs/modules/twitchAPI.object.base.rst: -------------------------------------------------------------------------------- 1 | 2 | .. automodule:: twitchAPI.object.base 3 | :members: 4 | :undoc-members: 5 | :show-inheritance: 6 | :inherited-members: 7 | -------------------------------------------------------------------------------- /docs/modules/twitchAPI.chat.middleware.rst: -------------------------------------------------------------------------------- 1 | 2 | .. automodule:: twitchAPI.chat.middleware 3 | :members: 4 | :undoc-members: 5 | :show-inheritance: 6 | :inherited-members: -------------------------------------------------------------------------------- /docs/modules/twitchAPI.object.eventsub.rst: -------------------------------------------------------------------------------- 1 | 2 | .. automodule:: twitchAPI.object.eventsub 3 | :members: 4 | :undoc-members: 5 | :show-inheritance: 6 | :inherited-members: 7 | -------------------------------------------------------------------------------- /docs/modules/twitchAPI.eventsub.base.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | .. automodule:: twitchAPI.eventsub.base 4 | :members: 5 | :undoc-members: 6 | :show-inheritance: 7 | :inherited-members: 8 | -------------------------------------------------------------------------------- /docs/modules/twitchAPI.eventsub.webhook.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | .. automodule:: twitchAPI.eventsub.webhook 4 | :members: 5 | :undoc-members: 6 | :show-inheritance: 7 | :inherited-members: 8 | -------------------------------------------------------------------------------- /docs/modules/twitchAPI.eventsub.websocket.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | .. automodule:: twitchAPI.eventsub.websocket 4 | :members: 5 | :undoc-members: 6 | :show-inheritance: 7 | :inherited-members: 8 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | sphinx: 3 | configuration: docs/conf.py 4 | 5 | build: 6 | os: ubuntu-22.04 7 | tools: 8 | python: "3.10" 9 | 10 | python: 11 | install: 12 | - requirements: docs/requirements.txt 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /venv/ 2 | /venv3.9/ 3 | /.idea/ 4 | __pycache__/ 5 | test_data.py 6 | example.py 7 | MANIFEST 8 | /dist/ 9 | /twitchAPI.egg-info/ 10 | /docs/_build/ 11 | /chat_example.py 12 | requirements_dev.txt 13 | /tests/ 14 | pytest.ini 15 | Makefile 16 | -------------------------------------------------------------------------------- /docs/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | .default-value-section .default-value-label { 2 | font-style: italic; 3 | } 4 | .bd-main .bd-content .bd-article-container { 5 | max-width: 100%; /* default is 60em */ 6 | } 7 | 8 | .bd-page-width { 9 | max-width: 88rem; /* default is 88rem */ 10 | } 11 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | enum-tools[sphinx] 2 | sphinx_toolbox 3 | aiohttp 4 | python-dateutil 5 | sphinx==8.1.3 6 | pygments 7 | typing_extensions 8 | sphinx-autodoc-typehints 9 | pydata-sphinx-theme==0.16.1 10 | recommonmark 11 | sphinx-paramlinks 12 | sphinx_favicon 13 | sphinx-copybutton 14 | sphinx-design 15 | -------------------------------------------------------------------------------- /twitchAPI/object/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023. Lena "Teekeks" During 2 | """ 3 | Objects used by this Library 4 | ---------------------------- 5 | 6 | .. toctree:: 7 | :hidden: 8 | :maxdepth: 1 9 | 10 | twitchAPI.object.base 11 | twitchAPI.object.api 12 | twitchAPI.object.eventsub 13 | 14 | .. autosummary:: 15 | 16 | base 17 | api 18 | eventsub 19 | 20 | """ 21 | 22 | __all__ = [] 23 | 24 | -------------------------------------------------------------------------------- /docs/tutorials.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | Tutorials 4 | ========= 5 | 6 | 7 | This is a collection of detailed Tutorials regarding this library. 8 | 9 | If you want to suggest a tutorial topic, you can do so here on github: https://github.com/Teekeks/pyTwitchAPI/discussions/213 10 | 11 | Available Tutorials 12 | ------------------- 13 | 14 | .. toctree:: 15 | :maxdepth: 1 16 | 17 | tutorial/mocking 18 | tutorial/reuse-user-token 19 | tutorial/user-auth-headless 20 | tutorial/chat-use-middleware 21 | -------------------------------------------------------------------------------- /docs/_static/switcher.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "develop", 4 | "version": "latest", 5 | "url": "https://pytwitchapi.dev/en/latest/" 6 | }, 7 | { 8 | "name": "4.5.0 (stable)", 9 | "version": "stable", 10 | "url": "https://pytwitchapi.dev/en/stable/", 11 | "preferred": true 12 | }, 13 | { 14 | "name": "3.11.0", 15 | "version": "v3.11.0", 16 | "url": "https://pytwitchapi.dev/en/v3.11.0/" 17 | }, 18 | { 19 | "name": "2.5.7", 20 | "version": "v2.5.7", 21 | "url": "https://pytwitchapi.dev/en/v2.5.7/" 22 | } 23 | ] 24 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Lena "Teekeks" During 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name=twitchAPI 3 | url=https://github.com/Teekeks/pyTwitchAPI 4 | author=Lena "Teekeks" During 5 | author_email=info@teawork.de 6 | description=A Python 3.7+ implementation of the Twitch Helix API, EventSub and Chat 7 | long_description=file:README.md 8 | long_description_content_type=text/markdown 9 | license=MIT 10 | classifiers= 11 | Development Status :: 5 - Production/Stable 12 | Intended Audience :: Developers 13 | Topic :: Communications 14 | Topic :: Software Development :: Libraries 15 | Topic :: Software Development :: Libraries :: Python Modules 16 | Topic :: Software Development :: Libraries :: Application Frameworks 17 | License :: OSI Approved :: MIT License 18 | Programming Language :: Python 19 | Programming Language :: Python :: 3 20 | Programming Language :: Python :: 3.7 21 | Programming Language :: Python :: 3.8 22 | Programming Language :: Python :: 3.9 23 | Programming Language :: Python :: 3.10 24 | Programming Language :: Python :: 3.11 25 | Programming Language :: Python :: 3.12 26 | 27 | [options] 28 | zip_safe=true 29 | install_requirements= 30 | aiohttp>=3.9.3 31 | python-dateutil>=2.8.2 32 | typing_extensions 33 | enum-tools 34 | 35 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022. Lena "Teekeks" During 2 | from setuptools import setup, find_packages 3 | 4 | version = '' 5 | 6 | with open('twitchAPI/__init__.py') as f: 7 | for line in f.readlines(): 8 | if line.startswith('__version__'): 9 | version = line.split('= \'')[-1][:-2].strip() 10 | 11 | if version.endswith(('a', 'b', 'rc')): 12 | try: 13 | import subprocess 14 | p = subprocess.Popen(['git', 'rev-list', '--count', 'HEAD'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) 15 | out, err = p.communicate() 16 | if out: 17 | version += out.decode('utf-8').strip() 18 | p = subprocess.Popen(['git', 'rev-parse', '--short', 'HEAD'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) 19 | out, err = p.communicate() 20 | if out: 21 | version += '+g' + out.decode('utf-8').strip() 22 | except: 23 | pass 24 | 25 | setup( 26 | packages=find_packages(), 27 | version=version, 28 | keywords=['twitch', 'twitch.tv', 'chat', 'bot', 'event sub', 'EventSub', 'helix', 'api'], 29 | install_requires=[ 30 | 'aiohttp>=3.9.3', 31 | 'python-dateutil>=2.8.2', 32 | 'typing_extensions', 33 | 'enum-tools' 34 | ], 35 | package_data={'twitchAPI': ['py.typed']} 36 | ) 37 | -------------------------------------------------------------------------------- /docs/_static/icons/pypi-icon.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * Set a custom icon for pypi as it's not available in the fa built-in brands 3 | * Thanks to pydata-sphinx theme 4 | */ 5 | FontAwesome.library.add( 6 | (faListOldStyle = { 7 | prefix: "fa-custom", 8 | iconName: "pypi", 9 | icon: [ 10 | 17.313, // viewBox width 11 | 19.807, // viewBox height 12 | [], // ligature 13 | "e001", // unicode codepoint - private use area 14 | "m10.383 0.2-3.239 1.1769 3.1883 1.1614 3.239-1.1798zm-3.4152 1.2411-3.2362 1.1769 3.1855 1.1614 3.2369-1.1769zm6.7177 0.00281-3.2947 1.2009v3.8254l3.2947-1.1988zm-3.4145 1.2439-3.2926 1.1981v3.8254l0.17548-0.064132 3.1171-1.1347zm-6.6564 0.018325v3.8247l3.244 1.1805v-3.8254zm10.191 0.20931v2.3137l3.1777-1.1558zm3.2947 1.2425-3.2947 1.1988v3.8254l3.2947-1.1988zm-8.7058 0.45739c0.00929-1.931e-4 0.018327-2.977e-4 0.027485 0 0.25633 0.00851 0.4263 0.20713 0.42638 0.49826 1.953e-4 0.38532-0.29327 0.80469-0.65542 0.93662-0.36226 0.13215-0.65608-0.073306-0.65613-0.4588-6.28e-5 -0.38556 0.2938-0.80504 0.65613-0.93662 0.068422-0.024919 0.13655-0.038114 0.20156-0.039466zm5.2913 0.78369-3.2947 1.1988v3.8247l3.2947-1.1981zm-10.132 1.239-3.2362 1.1769 3.1883 1.1614 3.2362-1.1769zm6.7177 0.00213-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4124 1.2439-3.2947 1.1988v3.8254l3.2947-1.1988zm-6.6585 0.016195v3.8275l3.244 1.1805v-3.8254zm16.9 0.21143-3.2947 1.1988v3.8247l3.2947-1.1981zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4124 1.2432-3.2947 1.1988v3.8254l3.2947-1.1988zm-6.6585 0.019027v3.8247l3.244 1.1805v-3.8254zm13.485 1.4497-3.2947 1.1988v3.8247l3.2947-1.1981zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm2.4018 0.38127c0.0093-1.83e-4 0.01833-3.16e-4 0.02749 0 0.25633 0.0085 0.4263 0.20713 0.42638 0.49826 1.97e-4 0.38532-0.29327 0.80469-0.65542 0.93662-0.36188 0.1316-0.65525-0.07375-0.65542-0.4588-1.95e-4 -0.38532 0.29328-0.80469 0.65542-0.93662 0.06842-0.02494 0.13655-0.03819 0.20156-0.03947zm-5.8142 0.86403-3.244 1.1805v1.4201l3.244 1.1805z", // svg path (https://simpleicons.org/icons/pypi.svg) 15 | ], 16 | }), 17 | ); 18 | -------------------------------------------------------------------------------- /docs/tutorial/user-auth-headless.rst: -------------------------------------------------------------------------------- 1 | Generate a User Auth Token Headless 2 | =================================== 3 | 4 | 5 | This is a example on how to integrate :const:`~twitchAPI.oauth.UserAuthenticator` into your headless app. 6 | 7 | This example uses the popular server software `flask `__ but it can easily adapted to other software. 8 | 9 | .. note:: Please make sure to add your redirect URL (in this example the value of ``MY_URL``) as a "OAuth Redirect URL" `here in your twitch dev dashboard `__ 10 | 11 | While this example works as is, it is highly likely that you need to modify this heavily in accordance with your use case. 12 | 13 | .. code-block:: python 14 | 15 | import asyncio 16 | from twitchAPI.twitch import Twitch 17 | from twitchAPI.oauth import UserAuthenticator 18 | from twitchAPI.type import AuthScope, TwitchAPIException 19 | from flask import Flask, redirect, request 20 | 21 | APP_ID = 'my_app_id' 22 | APP_SECRET = 'my_app_secret' 23 | TARGET_SCOPE = [AuthScope.CHAT_EDIT, AuthScope.CHAT_READ] 24 | MY_URL = 'http://localhost:5000/login/confirm' 25 | 26 | 27 | app = Flask(__name__) 28 | twitch: Twitch 29 | auth: UserAuthenticator 30 | 31 | 32 | @app.route('/login') 33 | def login(): 34 | return redirect(auth.return_auth_url()) 35 | 36 | 37 | @app.route('/login/confirm') 38 | async def login_confirm(): 39 | state = request.args.get('state') 40 | if state != auth.state: 41 | return 'Bad state', 401 42 | code = request.args.get('code') 43 | if code is None: 44 | return 'Missing code', 400 45 | try: 46 | token, refresh = await auth.authenticate(user_token=code) 47 | await twitch.set_user_authentication(token, TARGET_SCOPE, refresh) 48 | except TwitchAPIException as e: 49 | return 'Failed to generate auth token', 400 50 | return 'Sucessfully authenticated!' 51 | 52 | 53 | async def twitch_setup(): 54 | global twitch, auth 55 | twitch = await Twitch(APP_ID, APP_SECRET) 56 | auth = UserAuthenticator(twitch, TARGET_SCOPE, url=MY_URL) 57 | 58 | 59 | asyncio.run(twitch_setup()) 60 | 61 | -------------------------------------------------------------------------------- /docs/v4-migration.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | v3 to v4 migration guide 4 | ======================== 5 | 6 | With v4 of this library, some modules got reorganized and EventSub got a bunch of major changes. 7 | 8 | In this guide I will give some basic help on how to migrate your existing code. 9 | 10 | 11 | General module changes 12 | ---------------------- 13 | 14 | - ``twitchAPI.types`` was renamed to :const:`twitchAPI.type` 15 | - Most objects where moved from ``twitchAPI.object`` to :const:`twitchAPI.object.api` 16 | - The following Objects where moved from ``twitchAPI.object`` to :const:`twitchAPI.object.base`: 17 | 18 | - :const:`~twitchAPI.object.base.TwitchObject` 19 | - :const:`~twitchAPI.object.base.IterTwitchObject` 20 | - :const:`~twitchAPI.object.base.AsyncIterTwitchObject` 21 | 22 | EventSub 23 | -------- 24 | 25 | Eventsub has gained a new transport, the old ``EventSub`` is now located in the module :const:`twitchAPI.eventsub.webhook` and was renamed to :const:`~twitchAPI.eventsub.webhook.EventSubWebhook` 26 | 27 | Topic callbacks now no longer use plain dictionaries but objects. See :ref:`eventsub-available-topics` for more information which topic uses which object. 28 | 29 | .. code-block:: python 30 | :caption: V3 (before) 31 | 32 | from twitchAPI.eventsub import EventSub 33 | import asyncio 34 | 35 | EVENTSUB_URL = 'https://url.to.your.webhook.com' 36 | 37 | 38 | async def on_follow(data: dict): 39 | print(data) 40 | 41 | 42 | async def eventsub_example(): 43 | # twitch setup is left out of this example 44 | 45 | event_sub = EventSub(EVENTSUB_URL, APP_ID, 8080, twitch) 46 | await event_sub.unsubscribe_all() 47 | event_sub.start() 48 | await event_sub.listen_channel_follow_v2(user.id, user.id, on_follow) 49 | 50 | try: 51 | input('press Enter to shut down...') 52 | finally: 53 | await event_sub.stop() 54 | await twitch.close() 55 | print('done') 56 | 57 | 58 | asyncio.run(eventsub_example()) 59 | 60 | 61 | .. code-block:: python 62 | :caption: V4 (now) 63 | 64 | from twitchAPI.eventsub.webhook import EventSubWebhook 65 | from twitchAPI.object.eventsub import ChannelFollowEvent 66 | import asyncio 67 | 68 | EVENTSUB_URL = 'https://url.to.your.webhook.com' 69 | 70 | 71 | async def on_follow(data: ChannelFollowEvent): 72 | print(f'{data.event.user_name} now follows {data.event.broadcaster_user_name}!') 73 | 74 | 75 | async def eventsub_webhook_example(): 76 | # twitch setup is left out of this example 77 | 78 | eventsub = EventSubWebhook(EVENTSUB_URL, 8080, twitch) 79 | await eventsub.unsubscribe_all() 80 | eventsub.start() 81 | await eventsub.listen_channel_follow_v2(user.id, user.id, on_follow) 82 | 83 | try: 84 | input('press Enter to shut down...') 85 | finally: 86 | await eventsub.stop() 87 | await twitch.close() 88 | print('done') 89 | 90 | 91 | asyncio.run(eventsub_webhook_example()) 92 | -------------------------------------------------------------------------------- /docs/tutorial/reuse-user-token.rst: -------------------------------------------------------------------------------- 1 | Reuse user tokens with UserAuthenticationStorageHelper 2 | ====================================================== 3 | 4 | In this tutorial, we will look at different ways to use :const:`~twitchAPI.oauth.UserAuthenticationStorageHelper`. 5 | 6 | Basic Use Case 7 | -------------- 8 | 9 | This is the most basic example on how to use this helper. 10 | It will store any generated token in a file named `user_token.json` in your current folder and automatically update that file with refreshed tokens. 11 | Should the file not exists, the auth scope not match the one of the stored auth token or the token + refresh token no longer be valid, it will use :const:`~twitchAPI.oauth.UserAuthenticator` to generate a new one. 12 | 13 | .. code-block:: python 14 | :linenos: 15 | 16 | from twitchAPI import Twitch 17 | from twitchAPI.oauth import UserAuthenticationStorageHelper 18 | from twitchAPI.types import AuthScope 19 | 20 | APP_ID = 'my_app_id' 21 | APP_SECRET = 'my_app_secret' 22 | USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] 23 | 24 | 25 | async def run(): 26 | twitch = await Twitch(APP_ID, APP_SECRET) 27 | helper = UserAuthenticationStorageHelper(twitch, USER_SCOPE) 28 | await helper.bind() 29 | # do things 30 | 31 | await twitch.close() 32 | 33 | 34 | # lets run our setup 35 | asyncio.run(run()) 36 | 37 | 38 | Use a different file to store your token 39 | ---------------------------------------- 40 | 41 | You can specify a different file in which the token should be stored in like this: 42 | 43 | 44 | .. code-block:: python 45 | :linenos: 46 | :emphasize-lines: 4, 15 47 | 48 | from twitchAPI import Twitch 49 | from twitchAPI.oauth import UserAuthenticationStorageHelper 50 | from twitchAPI.types import AuthScope 51 | from pathlib import PurePath 52 | 53 | APP_ID = 'my_app_id' 54 | APP_SECRET = 'my_app_secret' 55 | USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] 56 | 57 | 58 | async def run(): 59 | twitch = await Twitch(APP_ID, APP_SECRET) 60 | helper = UserAuthenticationStorageHelper(twitch, 61 | USER_SCOPE, 62 | storage_path=PurePath('/my/new/path/file.json')) 63 | await helper.bind() 64 | # do things 65 | 66 | await twitch.close() 67 | 68 | 69 | # lets run our setup 70 | asyncio.run(run()) 71 | 72 | 73 | Use custom token generation code 74 | -------------------------------- 75 | 76 | Sometimes (for example for headless setups), the default UserAuthenticator is not good enough. 77 | For these cases, you can use your own function. 78 | 79 | .. code-block:: python 80 | :linenos: 81 | :emphasize-lines: 10, 11, 12, 18 82 | 83 | from twitchAPI import Twitch 84 | from twitchAPI.oauth import UserAuthenticationStorageHelper 85 | from twitchAPI.types import AuthScope 86 | 87 | APP_ID = 'my_app_id' 88 | APP_SECRET = 'my_app_secret' 89 | USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] 90 | 91 | 92 | async def my_token_generator(twitch: Twitch, target_scope: List[AuthScope]) -> (str, str): 93 | # generate new token + refresh token here and return it 94 | return 'token', 'refresh_token' 95 | 96 | async def run(): 97 | twitch = await Twitch(APP_ID, APP_SECRET) 98 | helper = UserAuthenticationStorageHelper(twitch, 99 | USER_SCOPE, 100 | auth_generator_func=my_token_generator) 101 | await helper.bind() 102 | # do things 103 | 104 | await twitch.close() 105 | 106 | 107 | # lets run our setup 108 | asyncio.run(run()) 109 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | import aiohttp 16 | import datetime 17 | sys.path.insert(0, os.path.abspath('..')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = 'twitchAPI' 23 | copyright = f'{datetime.date.today().year}, Lena "Teekeks" During' 24 | author = 'Lena "Teekeks" During' 25 | 26 | # The full version, including alpha/beta/rc tags 27 | release = None 28 | with open('../twitchAPI/__init__.py') as f: 29 | for line in f.readlines(): 30 | if line.startswith('__version__'): 31 | release = 'v' + line.split('= \'')[-1][:-2].strip() 32 | if release is None: 33 | release = 'dev' 34 | 35 | language = 'en' 36 | 37 | master_doc = 'index' 38 | 39 | add_module_names = True 40 | 41 | show_warning_types = True 42 | 43 | 44 | # -- General configuration --------------------------------------------------- 45 | 46 | # Add any Sphinx extension module names here, as strings. They can be 47 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 48 | # ones. 49 | extensions = [ 50 | 'sphinx.ext.autodoc', 51 | 'sphinx_autodoc_typehints', 52 | 'sphinx.ext.intersphinx', 53 | 'sphinx.ext.autosummary', 54 | 'enum_tools.autoenum', 55 | 'recommonmark', 56 | 'sphinx_paramlinks', 57 | 'sphinx_favicon', 58 | 'sphinx_copybutton', 59 | 'sphinx_design' 60 | ] 61 | 62 | aiohttp.client.ClientTimeout.__module__ = 'aiohttp' 63 | aiohttp.ClientTimeout.__module__ = 'aiohttp' 64 | 65 | autodoc_member_order = 'bysource' 66 | autodoc_class_signature = 'separated' 67 | 68 | # Add any paths that contain templates here, relative to this directory. 69 | templates_path = ['_templates'] 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | # This pattern also affects html_static_path and html_extra_path. 74 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 75 | 76 | intersphinx_mapping = { 77 | 'python': ('https://docs.python.org/3', None), 78 | 'aio': ('https://docs.aiohttp.org/en/stable/', None) 79 | } 80 | 81 | rst_epilog = """ 82 | .. |default| raw:: html 83 | 84 |
Default: 85 | 86 | .. |br| raw:: html 87 | 88 |
89 | """ 90 | 91 | 92 | def setup(app): 93 | app.add_css_file('css/custom.css') 94 | 95 | 96 | html_theme = 'pydata_sphinx_theme' 97 | 98 | # Define the json_url for our version switcher. 99 | json_url = "/en/latest/_static/switcher.json" 100 | 101 | # Define the version we use for matching in the version switcher. 102 | version_match = os.environ.get("READTHEDOCS_VERSION") 103 | # If READTHEDOCS_VERSION doesn't exist, we're not on RTD 104 | # If it is an integer, we're in a PR build and the version isn't correct. 105 | if not version_match or version_match.isdigit(): 106 | # For local development, infer the version to match from the package. 107 | # release = release 108 | if "-a" in release or "-b" in release or "rc" in release: 109 | version_match = "develop" 110 | # We want to keep the relative reference if we are in dev mode 111 | # but we want the whole url if we are effectively in a released version 112 | json_url = "/en/latest/_static/switcher.json" 113 | else: 114 | version_match = release 115 | 116 | html_theme_options = { 117 | "switcher": { 118 | "json_url": json_url, 119 | "version_match": version_match, 120 | }, 121 | "header_links_before_dropdown": 4, 122 | "navbar_center": ["version-switcher", "navbar-nav"], 123 | "pygments_dark_style": "monokai", 124 | "navbar_align": "left", 125 | "logo": { 126 | "text": "twitchAPI", 127 | "image_light": "logo.png", 128 | "image_dark": "logo.png" 129 | }, 130 | "icon_links": [ 131 | { 132 | "name": "GitHub", 133 | "url": "https://github.com/Teekeks/pyTwitchAPI", 134 | "icon": "fa-brands fa-github", 135 | }, 136 | { 137 | "name": "PyPI", 138 | "url": "https://pypi.org/project/twitchAPI", 139 | "icon": "fa-custom fa-pypi", 140 | }, 141 | { 142 | "name": "Discord Support Server", 143 | "url": "https://discord.gg/tu2Dmc7gpd", 144 | "icon": "fa-brands fa-discord", 145 | } 146 | ], 147 | "secondary_sidebar_items": { 148 | "**": ["page-toc"] 149 | } 150 | } 151 | # remove left sidebar 152 | html_sidebars = { 153 | "**": [] 154 | } 155 | 156 | favicons = [ 157 | "logo-32x32.png", 158 | "logo-16x16.png", 159 | {"rel": "shortcut icon", "sizes": "any", "href": "logo.ico"}, 160 | ] 161 | 162 | # Add any paths that contain custom static files (such as style sheets) here, 163 | # relative to this directory. They are copied after the builtin static files, 164 | # so a file named "default.css" will overwrite the builtin "default.css". 165 | html_static_path = ['_static'] 166 | html_js_files = ['icons/pypi-icon.js'] 167 | -------------------------------------------------------------------------------- /docs/v3-migration.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | v2 to v3 migration guide 4 | ======================== 5 | 6 | With version 3, this library made the switch from being mixed sync and async to being fully async. 7 | On top of that, it also switched from returning the mostly raw api response as dictionaries over to using objects and generators, making the overall usability easier. 8 | 9 | But this means that v2 and v3 are not compatible. 10 | 11 | In this guide I will give some basic help on how to migrate your existing code. 12 | 13 | .. note:: This guide will only show a few examples, please read the documentation for everything you use carefully, its likely that something has changed with every single one! 14 | 15 | 16 | **Please note that any call mentioned here that starts with a** :code:`await` **will have to be called inside a async function even if not displayed as such!** 17 | 18 | 19 | Library Initialization 20 | ---------------------- 21 | 22 | You now need to await the Twitch Object and refresh callbacks are now async. 23 | 24 | .. code-block:: python 25 | :caption: V2 (before) 26 | 27 | from twitchAPI.twitch import Twitch 28 | 29 | def user_refresh(token: str, refresh_token: str): 30 | print(f'my new user token is: {token}') 31 | 32 | def app_refresh(token: str): 33 | print(f'my new app token is: {token}') 34 | 35 | twitch = Twitch('app_id', 'app_secret') 36 | twitch.app_auth_refresh_callback = app_refresh 37 | twitch.user_auth_refresh_callback = user_refresh 38 | 39 | 40 | .. code-block:: python 41 | :caption: V3 (now) 42 | 43 | from twitchAPI.twitch import Twitch 44 | 45 | async def user_refresh(token: str, refresh_token: str): 46 | print(f'my new user token is: {token}') 47 | 48 | async def app_refresh(token: str): 49 | print(f'my new app token is: {token}') 50 | 51 | twitch = await Twitch('my_app_id', 'my_app_secret') 52 | twitch.app_auth_refresh_callback = app_refresh 53 | twitch.user_auth_refresh_callback = user_refresh 54 | 55 | 56 | Working with the API results 57 | ---------------------------- 58 | 59 | As detailed above, the API now returns Objects instead of pure dictionaries. 60 | 61 | Below are how each one has to be handled. View the documentation of each API method to see which type is returned. 62 | 63 | TwitchObject 64 | ^^^^^^^^^^^^ 65 | 66 | A lot of API calls return a child of :py:const:`~twitchAPI.object.TwitchObject` in some way (either directly or via generator). 67 | You can always use the :py:const:`~twitchAPI.object.TwitchObject.to_dict()` method to turn that object to a dictionary. 68 | 69 | Example: 70 | 71 | .. code-block:: python 72 | 73 | blocked_term = await twitch.add_blocked_term('broadcaster_id', 'moderator_id', 'bad_word') 74 | print(blocked_term.id) 75 | 76 | 77 | IterTwitchObject 78 | ^^^^^^^^^^^^^^^^ 79 | 80 | Some API calls return a special type of TwitchObject. 81 | These usually have some list inside that you may want to dicrectly itterate over in your API usage but that also contain other usefull data 82 | outside of that List. 83 | 84 | 85 | Example: 86 | 87 | .. code-block:: python 88 | 89 | lb = await twitch.get_bits_leaderboard() 90 | print(lb.total) 91 | for e in lb: 92 | print(f'#{e.rank:02d} - {e.user_name}: {e.score}') 93 | 94 | 95 | AsyncIterTwitchObject 96 | ^^^^^^^^^^^^^^^^^^^^^ 97 | 98 | A few API calls will have usefull data outside of the list the pagination itterates over. 99 | For those cases, this object exist. 100 | 101 | Example: 102 | 103 | .. code-block:: python 104 | 105 | schedule = await twitch.get_channel_stream_schedule('user_id') 106 | print(schedule.broadcaster_name) 107 | async for segment in schedule: 108 | print(segment.title) 109 | 110 | 111 | AsyncGenerator 112 | ^^^^^^^^^^^^^^ 113 | 114 | AsyncGenerators are used to automatically itterate over all possible resuts of your API call, this will also automatically handle pagination for you. 115 | In some cases (for example stream schedules with repeating entries), this may result in a endless stream of entries returned so make sure to add your own 116 | exit conditions in such cases. 117 | The generated objects will always be children of :py:const:`~twitchAPI.object.TwitchObject`, see the docs of the API call to see the exact object type. 118 | 119 | Example: 120 | 121 | .. code-block:: python 122 | 123 | async for tag in twitch.get_all_stream_tags(): 124 | print(tag.tag_id) 125 | 126 | 127 | PubSub 128 | ------ 129 | 130 | All callbacks are now async. 131 | 132 | .. code-block:: python 133 | :caption: V2 (before) 134 | 135 | # this will be called 136 | def callback_whisper(uuid: UUID, data: dict) -> None: 137 | print('got callback for UUID ' + str(uuid)) 138 | pprint(data) 139 | 140 | .. code-block:: python 141 | :caption: V3 (now) 142 | 143 | async def callback_whisper(uuid: UUID, data: dict) -> None: 144 | print('got callback for UUID ' + str(uuid)) 145 | pprint(data) 146 | 147 | 148 | EventSub 149 | -------- 150 | 151 | All `listen_` and `unsubscribe_` functions are now async 152 | 153 | .. code-block:: python 154 | :caption: listen and unsubscribe in V2 (before) 155 | 156 | event_sub.unsubscribe_all() 157 | event_sub.listen_channel_follow(user_id, on_follow) 158 | 159 | .. code-block:: python 160 | :caption: listen and unsubscribe in V3 (now) 161 | 162 | await event_sub.unsubscribe_all() 163 | await event_sub.listen_channel_follow(user_id, on_follow) 164 | 165 | 166 | :const:`~twitchAPI.eventsub.EventSub.stop()` is now async 167 | 168 | .. code-block:: python 169 | :caption: stop() in V2 (before) 170 | 171 | event_sub.stop() 172 | 173 | .. code-block:: python 174 | :caption: stop() in V3 (now) 175 | 176 | await event_sub.stop() 177 | -------------------------------------------------------------------------------- /docs/tutorial/mocking.rst: -------------------------------------------------------------------------------- 1 | Mocking with twitch-cli 2 | ======================= 3 | 4 | Twitch CLI is a tool provided by twitch which can be used to mock API calls and EventSub. 5 | 6 | To get started, first install and set up ``twitch-cli`` as described here: https://dev.twitch.tv/docs/cli/ 7 | 8 | 9 | Basic setup 10 | ----------- 11 | 12 | First, run ``twitch mock-api generate`` once and note down the Client ID and secret as well as the ID from the line reading `User ID 53100947 has all applicable units`. 13 | 14 | To run the mock server, run ``twitch mock-api start`` 15 | 16 | Mocking App Authentication and API 17 | ---------------------------------- 18 | 19 | The following code example sets us up with app auth and uses the mock API to get user information: 20 | 21 | .. code-block:: python 22 | 23 | import asyncio 24 | from twitchAPI.helper import first 25 | from twitchAPI.twitch import Twitch 26 | 27 | CLIENT_ID = 'GENERATED_CLIENT_ID' 28 | CLIENT_SECRET = 'GENERATED_CLIENT_SECRET' 29 | USER_ID = '53100947' 30 | 31 | 32 | async def run(): 33 | twitch = await Twitch(CLIENT_ID, 34 | CLIENT_SECRET, 35 | base_url='http://localhost:8080/mock/', 36 | auth_base_url='http://localhost:8080/auth/') 37 | user = await first(twitch.get_users(user_ids=USER_ID)) 38 | print(user.login) 39 | await twitch.close() 40 | 41 | 42 | asyncio.run(run()) 43 | 44 | 45 | Mocking User Authentication 46 | --------------------------- 47 | 48 | In the following example you see how to set up mocking with a user authentication. 49 | 50 | Note that :const:`~twitchAPI.twitch.Twitch.auto_refresh_auth` has to be set to `False` since the mock API does not return a refresh token. 51 | 52 | .. code-block:: python 53 | 54 | import asyncio 55 | from twitchAPI.oauth import UserAuthenticator 56 | from twitchAPI.helper import first 57 | from twitchAPI.twitch import Twitch 58 | 59 | CLIENT_ID = 'GENERATED_CLIENT_ID' 60 | CLIENT_SECRET = 'GENERATED_CLIENT_SECRET' 61 | USER_ID = '53100947' 62 | 63 | 64 | async def run(): 65 | twitch = await Twitch(CLIENT_ID, 66 | CLIENT_SECRET, 67 | base_url='http://localhost:8080/mock/', 68 | auth_base_url='http://localhost:8080/auth/') 69 | twitch.auto_refresh_auth = False 70 | auth = UserAuthenticator(twitch, [], auth_base_url='http://localhost:8080/auth/') 71 | token = await auth.mock_authenticate(USER_ID) 72 | await twitch.set_user_authentication(token, []) 73 | user = await first(twitch.get_users()) 74 | print(user.login) 75 | await twitch.close() 76 | 77 | 78 | asyncio.run(run()) 79 | 80 | Mocking EventSub Webhook 81 | ------------------------ 82 | 83 | Since the EventSub subscription endpoints are not mocked in twitch-cli, we need to subscribe to events on the live api. 84 | But we can then trigger events from within twitch-cli. 85 | 86 | The following example subscribes to the ``channel.subscribe`` event and then prints the command to be used to trigger the event via twitch-cli to console. 87 | 88 | .. code-block:: python 89 | 90 | import asyncio 91 | from twitchAPI.oauth import UserAuthenticationStorageHelper 92 | from twitchAPI.eventsub.webhook import EventSubWebhook 93 | from twitchAPI.object.eventsub import ChannelSubscribeEvent 94 | from twitchAPI.helper import first 95 | from twitchAPI.twitch import Twitch 96 | from twitchAPI.type import AuthScope 97 | 98 | CLIENT_ID = 'REAL_CLIENT_ID' 99 | CLIENT_SECRET = 'REAL_CLIENT_SECRET' 100 | EVENTSUB_URL = 'https://my.eventsub.url' 101 | 102 | 103 | async def on_subscribe(data: ChannelSubscribeEvent): 104 | print(f'{data.event.user_name} just subscribed!') 105 | 106 | 107 | async def run(): 108 | twitch = await Twitch(CLIENT_ID, 109 | CLIENT_SECRET) 110 | auth = UserAuthenticationStorageHelper(twitch, [AuthScope.CHANNEL_READ_SUBSCRIPTIONS]) 111 | await auth.bind() 112 | user = await first(twitch.get_users()) 113 | eventsub = EventSubWebhook(EVENTSUB_URL, 8080, twitch) 114 | eventsub.start() 115 | sub_id = await eventsub.listen_channel_subscribe(user.id, on_subscribe) 116 | print(f'twitch event trigger channel.subscribe -F {EVENTSUB_URL}/callback -t {user.id} -u {sub_id} -s {eventsub.secret}') 117 | 118 | try: 119 | input('press ENTER to stop') 120 | finally: 121 | await eventsub.stop() 122 | await twitch.close() 123 | 124 | 125 | asyncio.run(run()) 126 | 127 | 128 | Mocking EventSub Websocket 129 | -------------------------- 130 | 131 | For EventSub Websocket to work, you first have to run the following command to start a websocket server in addition to the API server: ``twitch event websocket start`` 132 | 133 | We once again mock both the app and user auth. 134 | 135 | The following example subscribes to the ``channel.subscribe`` event and then prints the command to be used to trigger the event via twitch-cli to console. 136 | 137 | .. code-block:: python 138 | 139 | import asyncio 140 | from twitchAPI.oauth import UserAuthenticator 141 | from twitchAPI.eventsub.websocket import EventSubWebsocket 142 | from twitchAPI.object.eventsub import ChannelSubscribeEvent 143 | from twitchAPI.helper import first 144 | from twitchAPI.twitch import Twitch 145 | from twitchAPI.type import AuthScope 146 | 147 | CLIENT_ID = 'GENERATED_CLIENT_ID' 148 | CLIENT_SECRET = 'GENERATED_CLIENT_SECRET' 149 | USER_ID = '53100947' 150 | 151 | 152 | async def on_subscribe(data: ChannelSubscribeEvent): 153 | print(f'{data.event.user_name} just subscribed!') 154 | 155 | 156 | async def run(): 157 | twitch = await Twitch(CLIENT_ID, 158 | CLIENT_SECRET, 159 | base_url='http://localhost:8080/mock/', 160 | auth_base_url='http://localhost:8080/auth/') 161 | twitch.auto_refresh_auth = False 162 | auth = UserAuthenticator(twitch, [AuthScope.CHANNEL_READ_SUBSCRIPTIONS], auth_base_url='http://localhost:8080/auth/') 163 | token = await auth.mock_authenticate(USER_ID) 164 | await twitch.set_user_authentication(token, [AuthScope.CHANNEL_READ_SUBSCRIPTIONS]) 165 | user = await first(twitch.get_users()) 166 | eventsub = EventSubWebsocket(twitch, 167 | connection_url='ws://127.0.0.1:8080/ws', 168 | subscription_url='http://127.0.0.1:8080/') 169 | eventsub.start() 170 | sub_id = await eventsub.listen_channel_subscribe(user.id, on_subscribe) 171 | print(f'twitch event trigger channel.subscribe -t {user.id} -u {sub_id} -T websocket') 172 | 173 | try: 174 | input('press ENTER to stop\n') 175 | finally: 176 | await eventsub.stop() 177 | await twitch.close() 178 | 179 | 180 | asyncio.run(run()) 181 | 182 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Twitch API 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/twitchAPI.svg)](https://pypi.org/project/twitchAPI/) [![Downloads](https://static.pepy.tech/badge/twitchapi)](https://pepy.tech/project/twitchapi) [![Python version](https://img.shields.io/pypi/pyversions/twitchAPI)](https://pypi.org/project/twitchAPI/) [![Twitch API version](https://img.shields.io/badge/twitch%20API%20version-Helix-brightgreen)](https://dev.twitch.tv/docs/api) [![Documentation Status](https://readthedocs.org/projects/pytwitchapi/badge/?version=latest)](https://pytwitchapi.readthedocs.io/en/latest/?badge=latest) 4 | 5 | 6 | This is a full implementation of the Twitch Helix API, EventSub and Chat in python 3.7+. 7 | 8 | 9 | ## Installation 10 | 11 | Install using pip: 12 | 13 | ```pip install twitchAPI``` 14 | 15 | ## Documentation and Support 16 | 17 | A full API documentation can be found [on readthedocs.org](https://pytwitchapi.readthedocs.io/en/stable/index.html). 18 | 19 | For support please join the [Twitch API discord server](https://discord.gg/tu2Dmc7gpd) 20 | 21 | ## Usage 22 | 23 | ### Basic API calls 24 | 25 | Setting up an Instance of the Twitch API and get your User ID: 26 | 27 | ```python 28 | from twitchAPI.twitch import Twitch 29 | from twitchAPI.helper import first 30 | import asyncio 31 | 32 | async def twitch_example(): 33 | # initialize the twitch instance, this will by default also create a app authentication for you 34 | twitch = await Twitch('app_id', 'app_secret') 35 | # call the API for the data of your twitch user 36 | # this returns a async generator that can be used to iterate over all results 37 | # but we are just interested in the first result 38 | # using the first helper makes this easy. 39 | user = await first(twitch.get_users(logins='your_twitch_user')) 40 | # print the ID of your user or do whatever else you want with it 41 | print(user.id) 42 | 43 | # run this example 44 | asyncio.run(twitch_example()) 45 | ``` 46 | 47 | ### Authentication 48 | 49 | The Twitch API knows 2 different authentications. App and User Authentication. 50 | Which one you need (or if one at all) depends on what calls you want to use. 51 | 52 | It's always good to get at least App authentication even for calls where you don't need it since the rate limits are way better for authenticated calls. 53 | 54 | **Please read the docs for more details and examples on how to set and use Authentication!** 55 | 56 | #### App Authentication 57 | 58 | App authentication is super simple, just do the following: 59 | 60 | ```python 61 | from twitchAPI.twitch import Twitch 62 | twitch = await Twitch('my_app_id', 'my_app_secret') 63 | ``` 64 | 65 | ### User Authentication 66 | 67 | To get a user auth token, the user has to explicitly click "Authorize" on the twitch website. You can use various online services to generate a token or use my build in Authenticator. 68 | For my Authenticator you have to add the following URL as a "OAuth Redirect URL": ```http://localhost:17563``` 69 | You can set that [here in your twitch dev dashboard](https://dev.twitch.tv/console). 70 | 71 | 72 | ```python 73 | from twitchAPI.twitch import Twitch 74 | from twitchAPI.oauth import UserAuthenticator 75 | from twitchAPI.type import AuthScope 76 | 77 | twitch = await Twitch('my_app_id', 'my_app_secret') 78 | 79 | target_scope = [AuthScope.BITS_READ] 80 | auth = UserAuthenticator(twitch, target_scope, force_verify=False) 81 | # this will open your default browser and prompt you with the twitch verification website 82 | token, refresh_token = await auth.authenticate() 83 | # add User authentication 84 | await twitch.set_user_authentication(token, target_scope, refresh_token) 85 | ``` 86 | 87 | You can reuse this token and use the refresh_token to renew it: 88 | 89 | ```python 90 | from twitchAPI.oauth import refresh_access_token 91 | new_token, new_refresh_token = await refresh_access_token('refresh_token', 'client_id', 'client_secret') 92 | ``` 93 | 94 | ### AuthToken refresh callback 95 | 96 | Optionally you can set a callback for both user access token refresh and app access token refresh. 97 | 98 | ```python 99 | from twitchAPI.twitch import Twitch 100 | 101 | async def user_refresh(token: str, refresh_token: str): 102 | print(f'my new user token is: {token}') 103 | 104 | async def app_refresh(token: str): 105 | print(f'my new app token is: {token}') 106 | 107 | twitch = await Twitch('my_app_id', 'my_app_secret') 108 | twitch.app_auth_refresh_callback = app_refresh 109 | twitch.user_auth_refresh_callback = user_refresh 110 | ``` 111 | 112 | ## EventSub 113 | 114 | EventSub lets you listen for events that happen on Twitch. 115 | 116 | The EventSub client runs in its own thread, calling the given callback function whenever an event happens. 117 | 118 | There are multiple EventSub transports available, used for different use cases. 119 | 120 | See here for more info about EventSub in general and the different Transports, including code examples: [on readthedocs](https://pytwitchapi.readthedocs.io/en/stable/modules/twitchAPI.eventsub.html) 121 | 122 | 123 | ## Chat 124 | 125 | A simple twitch chat bot. 126 | Chat bots can join channels, listen to chat and reply to messages, commands, subscriptions and many more. 127 | 128 | A more detailed documentation can be found [here on readthedocs](https://pytwitchapi.readthedocs.io/en/stable/modules/twitchAPI.chat.html) 129 | 130 | ### Example code for a simple bot 131 | 132 | ```python 133 | from twitchAPI.twitch import Twitch 134 | from twitchAPI.oauth import UserAuthenticator 135 | from twitchAPI.type import AuthScope, ChatEvent 136 | from twitchAPI.chat import Chat, EventData, ChatMessage, ChatSub, ChatCommand 137 | import asyncio 138 | 139 | APP_ID = 'my_app_id' 140 | APP_SECRET = 'my_app_secret' 141 | USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] 142 | TARGET_CHANNEL = 'teekeks42' 143 | 144 | 145 | # this will be called when the event READY is triggered, which will be on bot start 146 | async def on_ready(ready_event: EventData): 147 | print('Bot is ready for work, joining channels') 148 | # join our target channel, if you want to join multiple, either call join for each individually 149 | # or even better pass a list of channels as the argument 150 | await ready_event.chat.join_room(TARGET_CHANNEL) 151 | # you can do other bot initialization things in here 152 | 153 | 154 | # this will be called whenever a message in a channel was send by either the bot OR another user 155 | async def on_message(msg: ChatMessage): 156 | print(f'in {msg.room.name}, {msg.user.name} said: {msg.text}') 157 | 158 | 159 | # this will be called whenever someone subscribes to a channel 160 | async def on_sub(sub: ChatSub): 161 | print(f'New subscription in {sub.room.name}:\\n' 162 | f' Type: {sub.sub_plan}\\n' 163 | f' Message: {sub.sub_message}') 164 | 165 | 166 | # this will be called whenever the !reply command is issued 167 | async def test_command(cmd: ChatCommand): 168 | if len(cmd.parameter) == 0: 169 | await cmd.reply('you did not tell me what to reply with') 170 | else: 171 | await cmd.reply(f'{cmd.user.name}: {cmd.parameter}') 172 | 173 | 174 | # this is where we set up the bot 175 | async def run(): 176 | # set up twitch api instance and add user authentication with some scopes 177 | twitch = await Twitch(APP_ID, APP_SECRET) 178 | auth = UserAuthenticator(twitch, USER_SCOPE) 179 | token, refresh_token = await auth.authenticate() 180 | await twitch.set_user_authentication(token, USER_SCOPE, refresh_token) 181 | 182 | # create chat instance 183 | chat = await Chat(twitch) 184 | 185 | # register the handlers for the events you want 186 | 187 | # listen to when the bot is done starting up and ready to join channels 188 | chat.register_event(ChatEvent.READY, on_ready) 189 | # listen to chat messages 190 | chat.register_event(ChatEvent.MESSAGE, on_message) 191 | # listen to channel subscriptions 192 | chat.register_event(ChatEvent.SUB, on_sub) 193 | # there are more events, you can view them all in this documentation 194 | 195 | # you can directly register commands and their handlers, this will register the !reply command 196 | chat.register_command('reply', test_command) 197 | 198 | 199 | # we are done with our setup, lets start this bot up! 200 | chat.start() 201 | 202 | # lets run till we press enter in the console 203 | try: 204 | input('press ENTER to stop\n') 205 | finally: 206 | # now we can close the chat bot and the twitch api client 207 | chat.stop() 208 | await twitch.close() 209 | 210 | 211 | # lets run our setup 212 | asyncio.run(run()) 213 | ``` 214 | -------------------------------------------------------------------------------- /twitchAPI/object/base.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023. Lena "Teekeks" During 2 | """ 3 | Base Objects used by the Library 4 | -------------------------------- 5 | """ 6 | from datetime import datetime 7 | from enum import Enum 8 | from typing import TypeVar, Union, Generic, Optional 9 | 10 | from aiohttp import ClientSession 11 | from dateutil import parser as du_parser 12 | 13 | from twitchAPI.helper import build_url 14 | 15 | T = TypeVar('T') 16 | 17 | __all__ = ['TwitchObject', 'IterTwitchObject', 'AsyncIterTwitchObject'] 18 | 19 | 20 | class TwitchObject: 21 | """ 22 | A lot of API calls return a child of this in some way (either directly or via generator). 23 | You can always use the :const:`~twitchAPI.object.TwitchObject.to_dict()` method to turn that object to a dictionary. 24 | 25 | Example: 26 | 27 | .. code-block:: python 28 | 29 | blocked_term = await twitch.add_blocked_term('broadcaster_id', 'moderator_id', 'bad_word') 30 | print(blocked_term.id)""" 31 | @staticmethod 32 | def _val_by_instance(instance, val): 33 | if val is None: 34 | return None 35 | origin = instance.__origin__ if hasattr(instance, '__origin__') else None 36 | if instance == datetime: 37 | if isinstance(val, int): 38 | # assume unix timestamp 39 | return None if val == 0 else datetime.fromtimestamp(val) 40 | # assume ISO8601 string 41 | return du_parser.isoparse(val) if len(val) > 0 else None 42 | elif origin is list: 43 | c = instance.__args__[0] 44 | return [TwitchObject._val_by_instance(c, x) for x in val] 45 | elif origin is dict: 46 | c1 = instance.__args__[0] 47 | c2 = instance.__args__[1] 48 | return {TwitchObject._val_by_instance(c1, x1): TwitchObject._val_by_instance(c2, x2) for x1, x2 in val.items()} 49 | elif origin == Union: 50 | # TODO: only works for optional pattern, fix to try out all possible patterns? 51 | c1 = instance.__args__[0] 52 | return TwitchObject._val_by_instance(c1, val) 53 | elif issubclass(instance, TwitchObject): 54 | return instance(**val) 55 | else: 56 | return instance(val) 57 | 58 | @staticmethod 59 | def _dict_val_by_instance(instance, val, include_none_values): 60 | if val is None: 61 | return None 62 | if instance is None: 63 | return val 64 | origin = instance.__origin__ if hasattr(instance, '__origin__') else None 65 | if instance == datetime: 66 | return val.isoformat() if val is not None else None 67 | elif origin is list: 68 | c = instance.__args__[0] 69 | return [TwitchObject._dict_val_by_instance(c, x, include_none_values) for x in val] 70 | elif origin is dict: 71 | c1 = instance.__args__[0] 72 | c2 = instance.__args__[1] 73 | return {TwitchObject._dict_val_by_instance(c1, x1, include_none_values): 74 | TwitchObject._dict_val_by_instance(c2, x2, include_none_values) for x1, x2 in val.items()} 75 | elif origin is Union: 76 | # TODO: only works for optional pattern, fix to try out all possible patterns? 77 | c1 = instance.__args__[0] 78 | return TwitchObject._dict_val_by_instance(c1, val, include_none_values) 79 | elif issubclass(instance, TwitchObject): 80 | return val.to_dict(include_none_values) 81 | elif isinstance(val, Enum): 82 | return val.value 83 | return instance(val) 84 | 85 | @classmethod 86 | def _get_annotations(cls): 87 | d = {} 88 | for c in cls.mro(): 89 | try: 90 | d.update(**c.__annotations__) 91 | except AttributeError: 92 | pass 93 | return d 94 | 95 | def to_dict(self, include_none_values: bool = False) -> dict: 96 | """build dict based on annotation types 97 | 98 | :param include_none_values: if fields that have None values should be included in the dictionary 99 | """ 100 | d = {} 101 | annotations = self._get_annotations() 102 | for name, val in self.__dict__.items(): 103 | val = None 104 | cls = annotations.get(name) 105 | try: 106 | val = getattr(self, name) 107 | except AttributeError: 108 | pass 109 | if val is None and not include_none_values: 110 | continue 111 | if name[0] == '_': 112 | continue 113 | d[name] = TwitchObject._dict_val_by_instance(cls, val, include_none_values) 114 | return d 115 | 116 | def __init__(self, **kwargs): 117 | merged_annotations = self._get_annotations() 118 | for name, cls in merged_annotations.items(): 119 | if name not in kwargs.keys(): 120 | continue 121 | self.__setattr__(name, TwitchObject._val_by_instance(cls, kwargs.get(name))) 122 | 123 | def __repr__(self): 124 | merged_annotations = self._get_annotations() 125 | args = ', '.join(['='.join([name, str(getattr(self, name))]) for name in merged_annotations.keys() if hasattr(self, name)]) 126 | return f'{type(self).__name__}({args})' 127 | 128 | 129 | class IterTwitchObject(TwitchObject): 130 | """Special type of :const:`~twitchAPI.object.TwitchObject`. 131 | These usually have some list inside that you may want to directly iterate over in your API usage but that also contain other useful data 132 | outside of that List. 133 | 134 | Example: 135 | 136 | .. code-block:: python 137 | 138 | lb = await twitch.get_bits_leaderboard() 139 | print(lb.total) 140 | for e in lb: 141 | print(f'#{e.rank:02d} - {e.user_name}: {e.score}')""" 142 | 143 | def __iter__(self): 144 | if not hasattr(self, 'data') or not isinstance(self.__getattribute__('data'), list): 145 | raise ValueError('Object is missing data attribute of type list') 146 | for i in self.__getattribute__('data'): 147 | yield i 148 | 149 | 150 | class AsyncIterTwitchObject(TwitchObject, Generic[T]): 151 | """A few API calls will have useful data outside the list the pagination iterates over. 152 | For those cases, this object exist. 153 | 154 | Example: 155 | 156 | .. code-block:: python 157 | 158 | schedule = await twitch.get_channel_stream_schedule('user_id') 159 | print(schedule.broadcaster_name) 160 | async for segment in schedule: 161 | print(segment.title)""" 162 | 163 | def __init__(self, _data, **kwargs): 164 | super(AsyncIterTwitchObject, self).__init__(**kwargs) 165 | self.__idx = 0 166 | self._data = _data 167 | 168 | def __aiter__(self): 169 | return self 170 | 171 | def current_cursor(self) -> Optional[str]: 172 | """Provides the currently used forward pagination cursor""" 173 | return self._data['param'].get('after') 174 | 175 | async def __anext__(self) -> T: 176 | if not hasattr(self, self._data['iter_field']) or not isinstance(self.__getattribute__(self._data['iter_field']), list): 177 | raise ValueError(f'Object is missing {self._data["iter_field"]} attribute of type list') 178 | data = self.__getattribute__(self._data['iter_field']) 179 | if len(data) > self.__idx: 180 | self.__idx += 1 181 | return data[self.__idx - 1] 182 | # make request 183 | if self._data['param']['after'] is None: 184 | raise StopAsyncIteration() 185 | _url = build_url(self._data['url'], self._data['param'], remove_none=True, split_lists=self._data['split']) 186 | async with ClientSession() as session: 187 | response = await self._data['req'](self._data['method'], session, _url, self._data['auth_t'], self._data['auth_s'], self._data['body']) 188 | _data = await response.json() 189 | _after = _data.get('pagination', {}).get('cursor') 190 | self._data['param']['after'] = _after 191 | if self._data['in_data']: 192 | _data = _data['data'] 193 | # refill data 194 | merged_annotations = self._get_annotations() 195 | for name, cls in merged_annotations.items(): 196 | if name not in _data.keys(): 197 | continue 198 | self.__setattr__(name, TwitchObject._val_by_instance(cls, _data.get(name))) 199 | data = self.__getattribute__(self._data['iter_field']) 200 | self.__idx = 1 201 | if len(data) == 0: 202 | raise StopAsyncIteration() 203 | return data[self.__idx - 1] 204 | -------------------------------------------------------------------------------- /twitchAPI/helper.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020. Lena "Teekeks" During 2 | """ 3 | Helper functions 4 | ----------------""" 5 | import asyncio 6 | import datetime 7 | import logging 8 | import time 9 | import urllib.parse 10 | import uuid 11 | from logging import Logger 12 | from typing import AsyncGenerator, TypeVar 13 | from enum import Enum 14 | 15 | from .type import AuthScope 16 | 17 | from typing import Union, List, Type, Optional, overload 18 | 19 | __all__ = ['first', 'limit', 'TWITCH_API_BASE_URL', 'TWITCH_AUTH_BASE_URL', 'TWITCH_CHAT_URL', 'TWITCH_EVENT_SUB_WEBSOCKET_URL', 20 | 'build_url', 'get_uuid', 'build_scope', 'fields_to_enum', 'make_enum', 21 | 'enum_value_or_none', 'datetime_to_str', 'remove_none_values', 'ResultType', 'RateLimitBucket', 'RATE_LIMIT_SIZES', 'done_task_callback'] 22 | 23 | T = TypeVar('T') 24 | 25 | TWITCH_API_BASE_URL: str = "https://api.twitch.tv/helix/" 26 | """The base url to the Twitch API endpoints""" 27 | TWITCH_AUTH_BASE_URL: str = "https://id.twitch.tv/oauth2/" 28 | """The base url to the twitch authentication endpoints""" 29 | TWITCH_CHAT_URL: str = "wss://irc-ws.chat.twitch.tv:443" 30 | """The url to the Twitch Chat websocket""" 31 | TWITCH_EVENT_SUB_WEBSOCKET_URL: str = 'wss://eventsub.wss.twitch.tv/ws' 32 | """The url to the Twitch EventSub websocket""" 33 | 34 | 35 | class ResultType(Enum): 36 | RETURN_TYPE = 0 37 | STATUS_CODE = 1 38 | TEXT = 2 39 | 40 | 41 | def build_url(url: str, params: dict, remove_none: bool = False, split_lists: bool = False, enum_value: bool = True) -> str: 42 | """Build a valid url string 43 | 44 | :param url: base URL 45 | :param params: dictionary of URL parameter 46 | :param remove_none: if set all params that have a None value get removed |default| :code:`False` 47 | :param split_lists: if set all params that are a list will be split over multiple url parameter with the same name |default| :code:`False` 48 | :param enum_value: if true, automatically get value string from Enum values |default| :code:`True` 49 | :return: URL 50 | """ 51 | 52 | def get_val(val): 53 | if not enum_value: 54 | return str(val) 55 | if isinstance(val, Enum): 56 | return str(val.value) 57 | return str(val) 58 | 59 | def add_param(res, k, v): 60 | if len(res) > 0: 61 | res += "&" 62 | res += str(k) 63 | if v is not None: 64 | res += "=" + urllib.parse.quote(get_val(v)) 65 | return res 66 | 67 | result = "" 68 | for key, value in params.items(): 69 | if value is None and remove_none: 70 | continue 71 | if split_lists and isinstance(value, list): 72 | for va in value: 73 | result = add_param(result, key, va) 74 | else: 75 | result = add_param(result, key, value) 76 | return url + (("?" + result) if len(result) > 0 else "") 77 | 78 | 79 | def get_uuid() -> uuid.UUID: 80 | """Returns a random UUID""" 81 | return uuid.uuid4() 82 | 83 | 84 | def build_scope(scopes: List[AuthScope]) -> str: 85 | """Builds a valid scope string from list 86 | 87 | :param scopes: list of :class:`~twitchAPI.type.AuthScope` 88 | :returns: the valid auth scope string 89 | """ 90 | return ' '.join([s.value for s in scopes]) 91 | 92 | 93 | @overload 94 | def fields_to_enum(data: dict, fields: List[str], _enum: Type[Enum], default: Optional[Enum]) -> dict: 95 | ... 96 | 97 | @overload 98 | def fields_to_enum(data: List[dict], fields: List[str], _enum: Type[Enum], default: Optional[Enum]) -> List[dict]: 99 | ... 100 | 101 | def fields_to_enum(data: Union[dict, list], 102 | fields: List[str], 103 | _enum: Type[Enum], 104 | default: Optional[Enum]) -> Union[dict, List]: 105 | """Iterates a dict or list and tries to replace every dict entry with key in fields with the correct Enum value 106 | 107 | :param data: dict or list 108 | :param fields: list of keys to be replaced 109 | :param _enum: Type of Enum to be replaced 110 | :param default: The default value if _enum does not contain the field value 111 | """ 112 | _enum_vals = [e.value for e in _enum.__members__.values()] 113 | 114 | def make_dict_field_enum(_data: dict, 115 | _fields: List[str], 116 | _enum: Type[Enum], 117 | _default: Optional[Enum]) -> Union[dict, Enum, None]: 118 | fd = _data 119 | if isinstance(_data, str): 120 | if _data not in _enum_vals: 121 | return _default 122 | else: 123 | return _enum(_data) 124 | for key, value in _data.items(): 125 | if isinstance(value, str): 126 | if key in fields: 127 | if value not in _enum_vals: 128 | fd[key] = _default 129 | else: 130 | fd[key] = _enum(value) 131 | elif isinstance(value, dict): 132 | fd[key] = make_dict_field_enum(value, _fields, _enum, _default) 133 | elif isinstance(value, list): 134 | fd[key] = fields_to_enum(value, _fields, _enum, _default) 135 | return fd 136 | 137 | if isinstance(data, list): 138 | return [make_dict_field_enum(d, fields, _enum, default) for d in data] 139 | else: 140 | return make_dict_field_enum(data, fields, _enum, default) 141 | 142 | 143 | def make_enum(data: Union[str, int], _enum: Type[Enum], default: Enum) -> Enum: 144 | """Takes in a value and maps it to the given Enum. If the value is not valid it will take the default. 145 | 146 | :param data: the value to map from 147 | :param _enum: the Enum type to map to 148 | :param default: the default value""" 149 | _enum_vals = [e.value for e in _enum.__members__.values()] 150 | if data in _enum_vals: 151 | return _enum(data) 152 | else: 153 | return default 154 | 155 | 156 | def enum_value_or_none(enum: Optional[Enum]) -> Union[None, str, int]: 157 | """Returns the value of the given Enum member or None 158 | 159 | :param enum: the Enum member""" 160 | return enum.value if enum is not None else None 161 | 162 | 163 | def datetime_to_str(dt: Optional[datetime.datetime]) -> Optional[str]: 164 | """ISO-8601 formats the given datetime, returns None if datetime is None 165 | 166 | :param dt: the datetime to format""" 167 | return dt.astimezone().isoformat() if dt is not None else None 168 | 169 | 170 | def remove_none_values(d: dict) -> dict: 171 | """Removes items where the value is None from the dict. 172 | This returns a new dict and does not manipulate the one given. 173 | 174 | :param d: the dict from which the None values should be removed""" 175 | return {k: v for k, v in d.items() if v is not None} 176 | 177 | 178 | async def first(gen: AsyncGenerator[T, None]) -> Optional[T]: 179 | """Returns the first value of the given AsyncGenerator 180 | 181 | Example: 182 | 183 | .. code-block:: python 184 | 185 | user = await first(twitch.get_users()) 186 | 187 | :param gen: The generator from which you want the first value""" 188 | try: 189 | return await gen.__anext__() 190 | except StopAsyncIteration: 191 | return None 192 | 193 | 194 | async def limit(gen: AsyncGenerator[T, None], num: int) -> AsyncGenerator[T, None]: 195 | """Limits the number of entries from the given AsyncGenerator to up to num. 196 | 197 | This example will give you the currently 5 most watched streams: 198 | 199 | .. code-block:: python 200 | 201 | async for stream in limit(twitch.get_streams(), 5): 202 | print(stream.title) 203 | 204 | :param gen: The generator from which you want the first n values 205 | :param num: the number of entries you want 206 | :raises ValueError: if num is less than 1 207 | """ 208 | if num < 1: 209 | raise ValueError('num has to be a int > 1') 210 | c = 0 211 | async for y in gen: 212 | c += 1 213 | if c > num: 214 | break 215 | yield y 216 | 217 | 218 | class RateLimitBucket: 219 | """Handler used for chat rate limiting""" 220 | 221 | def __init__(self, 222 | bucket_length: int, 223 | bucket_size: int, 224 | scope: str, 225 | logger: Optional[logging.Logger] = None): 226 | """ 227 | 228 | :param bucket_length: time in seconds the bucket is valid for 229 | :param bucket_size: the number of entries that can be put into the bucket 230 | :param scope: the scope of this bucket (used for logging) 231 | :param logger: the logger to be used. If None the default logger is used 232 | """ 233 | self.scope = scope 234 | self.bucket_length = float(bucket_length) 235 | self.bucket_size = bucket_size 236 | self.reset = None 237 | self.content = 0 238 | self.logger = logger 239 | self.lock: asyncio.Lock = asyncio.Lock() 240 | 241 | def get_delta(self, num: int) -> Optional[float]: 242 | current = time.time() 243 | if self.reset is None: 244 | self.reset = current + self.bucket_length 245 | if current >= self.reset: 246 | self.reset = current + self.bucket_length 247 | self.content = num 248 | else: 249 | self.content += num 250 | if self.content >= self.bucket_size: 251 | return self.reset - current 252 | return None 253 | 254 | def left(self) -> int: 255 | """Returns the space left in the current bucket""" 256 | return self.bucket_size - self.content 257 | 258 | def _warn(self, msg): 259 | if self.logger is not None: 260 | self.logger.warning(msg) 261 | else: 262 | logging.warning(msg) 263 | 264 | async def put(self, num: int = 1): 265 | """Puts :code:`num` uses into the current bucket and waits if rate limit is hit 266 | 267 | :param num: the number of uses put into the current bucket""" 268 | async with self.lock: 269 | delta = self.get_delta(num) 270 | if delta is not None: 271 | self._warn(f'Bucket {self.scope} got rate limited. waiting {delta:.2f}s...') 272 | await asyncio.sleep(delta + 0.05) 273 | 274 | 275 | RATE_LIMIT_SIZES = { 276 | 'user': 20, 277 | 'mod': 100 278 | } 279 | 280 | 281 | def done_task_callback(logger: Logger, task: asyncio.Task): 282 | """helper function used as a asyncio task done callback""" 283 | e = task.exception() 284 | if e is not None: 285 | logger.exception("Error while running callback", exc_info=e) 286 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. twitchAPI documentation master file, created by 2 | sphinx-quickstart on Sat Mar 28 12:49:23 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Python Twitch API 7 | ================= 8 | 9 | This is a full implementation of the Twitch Helix API, EventSub and Chat in python 3.7+. 10 | 11 | On Github: https://github.com/Teekeks/pyTwitchAPI 12 | 13 | On PyPi: https://pypi.org/project/twitchAPI/ 14 | 15 | Changelog: :doc:`changelog` 16 | 17 | Tutorials: :doc:`tutorials` 18 | 19 | 20 | .. note:: There were major changes to the library with version 4, see the :doc:`v4-migration` to learn how to migrate. 21 | 22 | 23 | Installation 24 | ============ 25 | 26 | Install using pip: 27 | 28 | ``pip install twitchAPI`` 29 | 30 | Support 31 | ======= 32 | 33 | For Support please join the `Twitch API Discord server `_. 34 | 35 | Usage 36 | ===== 37 | 38 | These are some basic usage examples, please visit the dedicated pages for more info. 39 | 40 | 41 | TwitchAPI 42 | --------- 43 | 44 | Calls to the Twitch Helix API, this is the base of this library. 45 | 46 | See here for more info: :doc:`/modules/twitchAPI.twitch` 47 | 48 | .. code-block:: python 49 | 50 | from twitchAPI.twitch import Twitch 51 | from twitchAPI.helper import first 52 | import asyncio 53 | 54 | async def twitch_example(): 55 | # initialize the twitch instance, this will by default also create a app authentication for you 56 | twitch = await Twitch('app_id', 'app_secret') 57 | # call the API for the data of your twitch user 58 | # this returns a async generator that can be used to iterate over all results 59 | # but we are just interested in the first result 60 | # using the first helper makes this easy. 61 | user = await first(twitch.get_users(logins='your_twitch_user')) 62 | # print the ID of your user or do whatever else you want with it 63 | print(user.id) 64 | 65 | # run this example 66 | asyncio.run(twitch_example()) 67 | 68 | 69 | 70 | Authentication 71 | -------------- 72 | 73 | The Twitch API knows 2 different authentications. App and User Authentication. 74 | Which one you need (or if one at all) depends on what calls you want to use. 75 | 76 | It's always good to get at least App authentication even for calls where you don't need it since the rate limits are way better for authenticated calls. 77 | 78 | See here for more info about user authentication: :doc:`/modules/twitchAPI.oauth` 79 | 80 | App Authentication 81 | ^^^^^^^^^^^^^^^^^^ 82 | 83 | App authentication is super simple, just do the following: 84 | 85 | .. code-block:: python 86 | 87 | from twitchAPI.twitch import Twitch 88 | twitch = await Twitch('my_app_id', 'my_app_secret') 89 | 90 | 91 | User Authentication 92 | ^^^^^^^^^^^^^^^^^^^ 93 | 94 | To get a user auth token, the user has to explicitly click "Authorize" on the twitch website. You can use various online services to generate a token or use my build in Authenticator. 95 | For my Authenticator you have to add the following URL as a "OAuth Redirect URL": :code:`http://localhost:17563` 96 | You can set that `here in your twitch dev dashboard `_. 97 | 98 | 99 | .. code-block:: python 100 | 101 | from twitchAPI.twitch import Twitch 102 | from twitchAPI.oauth import UserAuthenticator 103 | from twitchAPI.type import AuthScope 104 | 105 | twitch = await Twitch('my_app_id', 'my_app_secret') 106 | 107 | target_scope = [AuthScope.BITS_READ] 108 | auth = UserAuthenticator(twitch, target_scope, force_verify=False) 109 | # this will open your default browser and prompt you with the twitch verification website 110 | token, refresh_token = await auth.authenticate() 111 | # add User authentication 112 | await twitch.set_user_authentication(token, target_scope, refresh_token) 113 | 114 | 115 | You can reuse this token and use the refresh_token to renew it: 116 | 117 | .. code-block:: python 118 | 119 | from twitchAPI.oauth import refresh_access_token 120 | new_token, new_refresh_token = await refresh_access_token('refresh_token', 'client_id', 'client_secret') 121 | 122 | 123 | AuthToken refresh callback 124 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 125 | 126 | Optionally you can set a callback for both user access token refresh and app access token refresh. 127 | 128 | .. code-block:: python 129 | 130 | from twitchAPI.twitch import Twitch 131 | 132 | async def user_refresh(token: str, refresh_token: str): 133 | print(f'my new user token is: {token}') 134 | 135 | async def app_refresh(token: str): 136 | print(f'my new app token is: {token}') 137 | 138 | twitch = await Twitch('my_app_id', 'my_app_secret') 139 | twitch.app_auth_refresh_callback = app_refresh 140 | twitch.user_auth_refresh_callback = user_refresh 141 | 142 | 143 | EventSub 144 | -------- 145 | 146 | EventSub lets you listen for events that happen on Twitch. 147 | 148 | There are multiple EventSub transports available, used for different use cases. 149 | 150 | See here for more info about EventSub in general and the different Transports, including code examples: :doc:`/modules/twitchAPI.eventsub` 151 | 152 | 153 | Chat 154 | ---- 155 | 156 | A simple twitch chat bot. 157 | Chat bots can join channels, listen to chat and reply to messages, commands, subscriptions and many more. 158 | 159 | See here for more info: :doc:`/modules/twitchAPI.chat` 160 | 161 | .. code-block:: python 162 | 163 | from twitchAPI.twitch import Twitch 164 | from twitchAPI.oauth import UserAuthenticator 165 | from twitchAPI.type import AuthScope, ChatEvent 166 | from twitchAPI.chat import Chat, EventData, ChatMessage, ChatSub, ChatCommand 167 | import asyncio 168 | 169 | APP_ID = 'my_app_id' 170 | APP_SECRET = 'my_app_secret' 171 | USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] 172 | TARGET_CHANNEL = 'teekeks42' 173 | 174 | 175 | # this will be called when the event READY is triggered, which will be on bot start 176 | async def on_ready(ready_event: EventData): 177 | print('Bot is ready for work, joining channels') 178 | # join our target channel, if you want to join multiple, either call join for each individually 179 | # or even better pass a list of channels as the argument 180 | await ready_event.chat.join_room(TARGET_CHANNEL) 181 | # you can do other bot initialization things in here 182 | 183 | 184 | # this will be called whenever a message in a channel was send by either the bot OR another user 185 | async def on_message(msg: ChatMessage): 186 | print(f'in {msg.room.name}, {msg.user.name} said: {msg.text}') 187 | 188 | 189 | # this will be called whenever someone subscribes to a channel 190 | async def on_sub(sub: ChatSub): 191 | print(f'New subscription in {sub.room.name}:\\n' 192 | f' Type: {sub.sub_plan}\\n' 193 | f' Message: {sub.sub_message}') 194 | 195 | 196 | # this will be called whenever the !reply command is issued 197 | async def test_command(cmd: ChatCommand): 198 | if len(cmd.parameter) == 0: 199 | await cmd.reply('you did not tell me what to reply with') 200 | else: 201 | await cmd.reply(f'{cmd.user.name}: {cmd.parameter}') 202 | 203 | 204 | # this is where we set up the bot 205 | async def run(): 206 | # set up twitch api instance and add user authentication with some scopes 207 | twitch = await Twitch(APP_ID, APP_SECRET) 208 | auth = UserAuthenticator(twitch, USER_SCOPE) 209 | token, refresh_token = await auth.authenticate() 210 | await twitch.set_user_authentication(token, USER_SCOPE, refresh_token) 211 | 212 | # create chat instance 213 | chat = await Chat(twitch) 214 | 215 | # register the handlers for the events you want 216 | 217 | # listen to when the bot is done starting up and ready to join channels 218 | chat.register_event(ChatEvent.READY, on_ready) 219 | # listen to chat messages 220 | chat.register_event(ChatEvent.MESSAGE, on_message) 221 | # listen to channel subscriptions 222 | chat.register_event(ChatEvent.SUB, on_sub) 223 | # there are more events, you can view them all in this documentation 224 | 225 | # you can directly register commands and their handlers, this will register the !reply command 226 | chat.register_command('reply', test_command) 227 | 228 | 229 | # we are done with our setup, lets start this bot up! 230 | chat.start() 231 | 232 | # lets run till we press enter in the console 233 | try: 234 | input('press ENTER to stop\\n') 235 | finally: 236 | # now we can close the chat bot and the twitch api client 237 | chat.stop() 238 | await twitch.close() 239 | 240 | 241 | # lets run our setup 242 | asyncio.run(run()) 243 | 244 | Logging 245 | ======= 246 | 247 | This module uses the `logging` module for creating Logs. 248 | Valid loggers are: 249 | 250 | .. list-table:: 251 | :header-rows: 1 252 | 253 | * - Logger Name 254 | - Class 255 | - Variable 256 | * - :code:`twitchAPI.twitch` 257 | - :const:`~twitchAPI.twitch.Twitch` 258 | - :const:`~twitchAPI.twitch.Twitch.logger` 259 | * - :code:`twitchAPI.chat` 260 | - :const:`~twitchAPI.chat.Chat` 261 | - :const:`~twitchAPI.chat.Chat.logger` 262 | * - :code:`twitchAPI.eventsub.webhook` 263 | - :const:`~twitchAPI.eventsub.webhook.EventSubWebhook` 264 | - :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.logger` 265 | * - :code:`twitchAPI.eventsub.websocket` 266 | - :const:`~twitchAPI.eventsub.websocket.EventSubWebsocket` 267 | - :const:`~twitchAPI.eventsub.websocket.EventSubWebsocket.logger` 268 | * - :code:`twitchAPI.oauth` 269 | - :const:`~twitchAPI.oauth.UserAuthenticator` 270 | - :const:`~twitchAPI.oauth.UserAuthenticator.logger` 271 | * - :code:`twitchAPI.oauth.code_flow` 272 | - :const:`~twitchAPI.oauth.CodeFlow` 273 | - :const:`~twitchAPI.oauth.CodeFlow.logger` 274 | * - :code:`twitchAPI.oauth.storage_helper` 275 | - :const:`~twitchAPI.oauth.UserAuthenticationStorageHelper` 276 | - :const:`~twitchAPI.oauth.UserAuthenticationStorageHelper.logger` 277 | 278 | 279 | 280 | Indices and tables 281 | ================== 282 | 283 | * :ref:`genindex` 284 | * :ref:`modindex` 285 | * :doc:`tutorials` 286 | * :doc:`changelog` 287 | * :doc:`v3-migration` 288 | * :doc:`v4-migration` 289 | 290 | 291 | .. autosummary:: 292 | twitchAPI.twitch 293 | twitchAPI.eventsub 294 | twitchAPI.chat 295 | twitchAPI.chat.middleware 296 | twitchAPI.oauth 297 | twitchAPI.type 298 | twitchAPI.helper 299 | twitchAPI.object 300 | 301 | .. toctree:: 302 | :maxdepth: 2 303 | :caption: Contents: 304 | :hidden: 305 | 306 | modules/twitchAPI.twitch 307 | modules/twitchAPI.eventsub 308 | modules/twitchAPI.chat 309 | tutorials 310 | modules/twitchAPI.chat.middleware 311 | modules/twitchAPI.oauth 312 | modules/twitchAPI.type 313 | modules/twitchAPI.helper 314 | modules/twitchAPI.object 315 | changelog 316 | -------------------------------------------------------------------------------- /twitchAPI/chat/middleware.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023. Lena "Teekeks" During 2 | """ 3 | Chat Command Middleware 4 | ----------------------- 5 | 6 | A selection of preimplemented chat command middleware. 7 | 8 | .. note:: See :doc:`/tutorial/chat-use-middleware` for a more detailed walkthough on how to use these. 9 | 10 | Available Middleware 11 | ==================== 12 | 13 | .. list-table:: 14 | :header-rows: 1 15 | 16 | * - Middleware 17 | - Description 18 | * - :const:`~twitchAPI.chat.middleware.ChannelRestriction` 19 | - Filters in which channels a command can be executed in. 20 | * - :const:`~twitchAPI.chat.middleware.UserRestriction` 21 | - Filters which users can execute a command. 22 | * - :const:`~twitchAPI.chat.middleware.StreamerOnly` 23 | - Restricts the use of commands to only the streamer in their channel. 24 | * - :const:`~twitchAPI.chat.middleware.ChannelCommandCooldown` 25 | - Restricts a command to only be executed once every :const:`cooldown_seconds` in a channel regardless of user. 26 | * - :const:`~twitchAPI.chat.middleware.ChannelUserCommandCooldown` 27 | - Restricts a command to be only executed once every :const:`cooldown_seconds` in a channel by a user. 28 | * - :const:`~twitchAPI.chat.middleware.GlobalCommandCooldown` 29 | - Restricts a command to be only executed once every :const:`cooldown_seconds` in any channel. 30 | 31 | 32 | Class Documentation 33 | =================== 34 | 35 | """ 36 | from abc import ABC, abstractmethod 37 | from datetime import datetime 38 | from typing import Optional, List, TYPE_CHECKING, Callable, Awaitable, Dict 39 | 40 | if TYPE_CHECKING: 41 | from twitchAPI.chat import ChatCommand 42 | 43 | 44 | __all__ = ['BaseCommandMiddleware', 'ChannelRestriction', 'UserRestriction', 'StreamerOnly', 45 | 'ChannelCommandCooldown', 'ChannelUserCommandCooldown', 'GlobalCommandCooldown', 'SharedChatOnlyCurrent'] 46 | 47 | 48 | class BaseCommandMiddleware(ABC): 49 | """The base for chat command middleware, extend from this when implementing your own""" 50 | 51 | execute_blocked_handler: Optional[Callable[['ChatCommand'], Awaitable[None]]] = None 52 | """If set, this handler will be called should :const:`~twitchAPI.chat.middleware.BaseCommandMiddleware.can_execute()` fail.""" 53 | 54 | @abstractmethod 55 | async def can_execute(self, command: 'ChatCommand') -> bool: 56 | """ 57 | return :code:`True` if the given command should execute, otherwise :code:`False` 58 | 59 | :param command: The command to check if it should be executed""" 60 | pass 61 | 62 | @abstractmethod 63 | async def was_executed(self, command: 'ChatCommand'): 64 | """Will be called when a command was executed, use to update internal state""" 65 | pass 66 | 67 | 68 | class ChannelRestriction(BaseCommandMiddleware): 69 | """Filters in which channels a command can be executed in""" 70 | 71 | def __init__(self, 72 | allowed_channel: Optional[List[str]] = None, 73 | denied_channel: Optional[List[str]] = None, 74 | execute_blocked_handler: Optional[Callable[['ChatCommand'], Awaitable[None]]] = None): 75 | """ 76 | :param allowed_channel: if provided, the command can only be used in channels on this list 77 | :param denied_channel: if provided, the command can't be used in channels on this list 78 | :param execute_blocked_handler: optional specific handler for when the execution is blocked 79 | """ 80 | self.execute_blocked_handler = execute_blocked_handler 81 | self.allowed = allowed_channel if allowed_channel is not None else [] 82 | self.denied = denied_channel if denied_channel is not None else [] 83 | 84 | async def can_execute(self, command: 'ChatCommand') -> bool: 85 | if len(self.allowed) > 0: 86 | if command.room.name not in self.allowed: 87 | return False 88 | return command.room.name not in self.denied 89 | 90 | async def was_executed(self, command: 'ChatCommand'): 91 | pass 92 | 93 | 94 | class UserRestriction(BaseCommandMiddleware): 95 | """Filters which users can execute a command""" 96 | 97 | def __init__(self, 98 | allowed_users: Optional[List[str]] = None, 99 | denied_users: Optional[List[str]] = None, 100 | execute_blocked_handler: Optional[Callable[['ChatCommand'], Awaitable[None]]] = None): 101 | """ 102 | :param allowed_users: if provided, the command can only be used by one of the provided users 103 | :param denied_users: if provided, the command can not be used by any of the provided users 104 | :param execute_blocked_handler: optional specific handler for when the execution is blocked 105 | """ 106 | self.execute_blocked_handler = execute_blocked_handler 107 | self.allowed = allowed_users if allowed_users is not None else [] 108 | self.denied = denied_users if denied_users is not None else [] 109 | 110 | async def can_execute(self, command: 'ChatCommand') -> bool: 111 | if len(self.allowed) > 0: 112 | if command.user.name not in self.allowed: 113 | return False 114 | return command.user.name not in self.denied 115 | 116 | async def was_executed(self, command: 'ChatCommand'): 117 | pass 118 | 119 | 120 | class StreamerOnly(BaseCommandMiddleware): 121 | """Restricts the use of commands to only the streamer in their channel""" 122 | 123 | def __init__(self, execute_blocked_handler: Optional[Callable[['ChatCommand'], Awaitable[None]]] = None): 124 | """ 125 | :param execute_blocked_handler: optional specific handler for when the execution is blocked 126 | """ 127 | self.execute_blocked_handler = execute_blocked_handler 128 | 129 | async def can_execute(self, command: 'ChatCommand') -> bool: 130 | return command.room.name == command.user.name 131 | 132 | async def was_executed(self, command: 'ChatCommand'): 133 | pass 134 | 135 | 136 | class ChannelCommandCooldown(BaseCommandMiddleware): 137 | """Restricts a command to only be executed once every :const:`cooldown_seconds` in a channel regardless of user.""" 138 | 139 | # command -> channel -> datetime 140 | _last_executed: Dict[str, Dict[str, datetime]] = {} 141 | 142 | def __init__(self, 143 | cooldown_seconds: int, 144 | execute_blocked_handler: Optional[Callable[['ChatCommand'], Awaitable[None]]] = None): 145 | """ 146 | :param cooldown_seconds: time in seconds a command should not be used again 147 | :param execute_blocked_handler: optional specific handler for when the execution is blocked 148 | """ 149 | self.execute_blocked_handler = execute_blocked_handler 150 | self.cooldown = cooldown_seconds 151 | 152 | async def can_execute(self, command: 'ChatCommand') -> bool: 153 | if self._last_executed.get(command.name) is None: 154 | return True 155 | last_executed = self._last_executed[command.name].get(command.room.name) 156 | if last_executed is None: 157 | return True 158 | since = (datetime.now() - last_executed).total_seconds() 159 | return since >= self.cooldown 160 | 161 | async def was_executed(self, command: 'ChatCommand'): 162 | if self._last_executed.get(command.name) is None: 163 | self._last_executed[command.name] = {} 164 | self._last_executed[command.name][command.room.name] = datetime.now() 165 | return 166 | self._last_executed[command.name][command.room.name] = datetime.now() 167 | 168 | 169 | class ChannelUserCommandCooldown(BaseCommandMiddleware): 170 | """Restricts a command to be only executed once every :const:`cooldown_seconds` in a channel by a user.""" 171 | 172 | # command -> channel -> user -> datetime 173 | _last_executed: Dict[str, Dict[str, Dict[str, datetime]]] = {} 174 | 175 | def __init__(self, 176 | cooldown_seconds: int, 177 | execute_blocked_handler: Optional[Callable[['ChatCommand'], Awaitable[None]]] = None): 178 | """ 179 | :param cooldown_seconds: time in seconds a command should not be used again 180 | :param execute_blocked_handler: optional specific handler for when the execution is blocked 181 | """ 182 | self.execute_blocked_handler = execute_blocked_handler 183 | self.cooldown = cooldown_seconds 184 | 185 | async def can_execute(self, command: 'ChatCommand') -> bool: 186 | if self._last_executed.get(command.name) is None: 187 | return True 188 | if self._last_executed[command.name].get(command.room.name) is None: 189 | return True 190 | last_executed = self._last_executed[command.name][command.room.name].get(command.user.name) 191 | if last_executed is None: 192 | return True 193 | since = (datetime.now() - last_executed).total_seconds() 194 | return since >= self.cooldown 195 | 196 | async def was_executed(self, command: 'ChatCommand'): 197 | if self._last_executed.get(command.name) is None: 198 | self._last_executed[command.name] = {} 199 | self._last_executed[command.name][command.room.name] = {} 200 | self._last_executed[command.name][command.room.name][command.user.name] = datetime.now() 201 | return 202 | if self._last_executed[command.name].get(command.room.name) is None: 203 | self._last_executed[command.name][command.room.name] = {} 204 | self._last_executed[command.name][command.room.name][command.user.name] = datetime.now() 205 | return 206 | self._last_executed[command.name][command.room.name][command.user.name] = datetime.now() 207 | 208 | 209 | class GlobalCommandCooldown(BaseCommandMiddleware): 210 | """Restricts a command to be only executed once every :const:`cooldown_seconds` in any channel""" 211 | 212 | # command -> datetime 213 | _last_executed: Dict[str, datetime] = {} 214 | 215 | def __init__(self, 216 | cooldown_seconds: int, 217 | execute_blocked_handler: Optional[Callable[['ChatCommand'], Awaitable[None]]] = None): 218 | """ 219 | :param cooldown_seconds: time in seconds a command should not be used again 220 | :param execute_blocked_handler: optional specific handler for when the execution is blocked 221 | """ 222 | self.execute_blocked_handler = execute_blocked_handler 223 | self.cooldown = cooldown_seconds 224 | 225 | async def can_execute(self, command: 'ChatCommand') -> bool: 226 | if self._last_executed.get(command.name) is None: 227 | return True 228 | since = (datetime.now() - self._last_executed[command.name]).total_seconds() 229 | return since >= self.cooldown 230 | 231 | async def was_executed(self, command: 'ChatCommand'): 232 | self._last_executed[command.name] = datetime.now() 233 | 234 | 235 | class SharedChatOnlyCurrent(BaseCommandMiddleware): 236 | """Restricts commands to only current chat room in Shared Chat streams""" 237 | 238 | async def can_execute(self, command: 'ChatCommand') -> bool: 239 | if command.source_room_id != command.room.room_id: 240 | return False 241 | return True 242 | 243 | async def was_executed(self, command: 'ChatCommand'): 244 | pass 245 | -------------------------------------------------------------------------------- /docs/tutorial/chat-use-middleware.rst: -------------------------------------------------------------------------------- 1 | Chat - Introduction to Middleware 2 | ================================= 3 | 4 | In this tutorial, we will go over a few examples on how to use and write your own chat command middleware. 5 | 6 | Basics 7 | ****** 8 | 9 | Command Middleware can be understood as a set of filters which decide if a chat command should be executed by a user. 10 | A basic example would be the idea to limit the use of certain commands to just a few chat rooms or restricting the use of administrative commands to just the streamer. 11 | 12 | 13 | There are two types of command middleware: 14 | 15 | 1. global command middleware: this will be used to check any command that might be run 16 | 2. single command middleware: this will only be used to check a single command if it might be run 17 | 18 | 19 | Example setup 20 | ************* 21 | 22 | The following basic chat example will be used in this entire tutorial 23 | 24 | .. code-block:: python 25 | :linenos: 26 | 27 | import asyncio 28 | from twitchAPI import Twitch 29 | from twitchAPI.chat import Chat, ChatCommand 30 | from twitchAPI.oauth import UserAuthenticationStorageHelper 31 | from twitchAPI.types import AuthScope 32 | 33 | 34 | APP_ID = 'your_app_id' 35 | APP_SECRET = 'your_app_secret' 36 | SCOPES = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] 37 | TARGET_CHANNEL = ['your_first_channel', 'your_second_channel'] 38 | 39 | 40 | async def command_one(cmd: ChatCommand): 41 | await cmd.reply('This is the first command!') 42 | 43 | 44 | async def command_two(cmd: ChatCommand): 45 | await cmd.reply('This is the second command!') 46 | 47 | 48 | async def run(): 49 | twitch = await Twitch(APP_ID, APP_SECRET) 50 | helper = UserAuthenticationStorageHelper(twitch, SCOPES) 51 | await helper.bind() 52 | chat = await Chat(twitch, initial_channel=TARGET_CHANNEL) 53 | 54 | chat.register_command('one', command_one) 55 | chat.register_command('two', command_two) 56 | 57 | chat.start() 58 | try: 59 | input('press Enter to shut down...\n') 60 | except KeyboardInterrupt: 61 | pass 62 | finally: 63 | chat.stop() 64 | await twitch.close() 65 | 66 | 67 | asyncio.run(run()) 68 | 69 | 70 | Global Middleware 71 | ***************** 72 | 73 | Given the above example, we now want to restrict the use of all commands in a way that only user :code:`user1` can use them and that they can only be used in :code:`your_first_channel`. 74 | 75 | The highlighted lines in the code below show how easy it is to set this up: 76 | 77 | .. code-block:: python 78 | :linenos: 79 | :emphasize-lines: 4,28,29 80 | 81 | import asyncio 82 | from twitchAPI import Twitch 83 | from twitchAPI.chat import Chat, ChatCommand 84 | from twitchAPI.chat.middleware import UserRestriction, ChannelRestriction 85 | from twitchAPI.oauth import UserAuthenticationStorageHelper 86 | from twitchAPI.types import AuthScope 87 | 88 | 89 | APP_ID = 'your_app_id' 90 | APP_SECRET = 'your_app_secret' 91 | SCOPES = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] 92 | TARGET_CHANNEL = ['your_first_channel', 'your_second_channel'] 93 | 94 | 95 | async def command_one(cmd: ChatCommand): 96 | await cmd.reply('This is the first command!') 97 | 98 | 99 | async def command_two(cmd: ChatCommand): 100 | await cmd.reply('This is the second command!') 101 | 102 | 103 | async def run(): 104 | twitch = await Twitch(APP_ID, APP_SECRET) 105 | helper = UserAuthenticationStorageHelper(twitch, SCOPES) 106 | await helper.bind() 107 | chat = await Chat(twitch, initial_channel=TARGET_CHANNEL) 108 | chat.register_command_middleware(UserRestriction(allowed_users=['user1'])) 109 | chat.register_command_middleware(ChannelRestriction(allowed_channel=['your_first_channel'])) 110 | 111 | chat.register_command('one', command_one) 112 | chat.register_command('two', command_two) 113 | 114 | chat.start() 115 | try: 116 | input('press Enter to shut down...\n') 117 | except KeyboardInterrupt: 118 | pass 119 | finally: 120 | chat.stop() 121 | await twitch.close() 122 | 123 | 124 | asyncio.run(run()) 125 | 126 | Single Command Middleware 127 | ************************* 128 | 129 | Given the above example, we now want to only restrict :code:`!one` to be used by the streamer of the channel its executed in. 130 | 131 | The highlighted lines in the code below show how easy it is to set this up: 132 | 133 | .. code-block:: python 134 | :linenos: 135 | :emphasize-lines: 4, 29 136 | 137 | import asyncio 138 | from twitchAPI import Twitch 139 | from twitchAPI.chat import Chat, ChatCommand 140 | from twitchAPI.chat.middleware import StreamerOnly 141 | from twitchAPI.oauth import UserAuthenticationStorageHelper 142 | from twitchAPI.types import AuthScope 143 | 144 | 145 | APP_ID = 'your_app_id' 146 | APP_SECRET = 'your_app_secret' 147 | SCOPES = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] 148 | TARGET_CHANNEL = ['your_first_channel', 'your_second_channel'] 149 | 150 | 151 | async def command_one(cmd: ChatCommand): 152 | await cmd.reply('This is the first command!') 153 | 154 | 155 | async def command_two(cmd: ChatCommand): 156 | await cmd.reply('This is the second command!') 157 | 158 | 159 | async def run(): 160 | twitch = await Twitch(APP_ID, APP_SECRET) 161 | helper = UserAuthenticationStorageHelper(twitch, SCOPES) 162 | await helper.bind() 163 | chat = await Chat(twitch, initial_channel=TARGET_CHANNEL) 164 | 165 | chat.register_command('one', command_one, command_middleware=[StreamerOnly()]) 166 | chat.register_command('two', command_two) 167 | 168 | chat.start() 169 | try: 170 | input('press Enter to shut down...\n') 171 | except KeyboardInterrupt: 172 | pass 173 | finally: 174 | chat.stop() 175 | await twitch.close() 176 | 177 | 178 | asyncio.run(run()) 179 | 180 | 181 | Using Execute Blocked Handlers 182 | ****************************** 183 | 184 | Execute blocked handlers are a function which will be called whenever the execution of a command was blocked. 185 | 186 | You can define a default handler to be used for any middleware that blocks a command execution and/or set one per 187 | middleware that will only be used when that specific middleware blocked the execution of a command. 188 | 189 | Note: You can mix and match a default handler with middleware specific handlers as much as you want. 190 | 191 | Using a default handler 192 | ----------------------- 193 | 194 | A default handler will be called whenever the execution of a command is blocked by a middleware which has no specific handler set. 195 | 196 | You can define a simple handler which just replies to the user as follows using the global middleware example: 197 | 198 | :const:`handle_command_blocked()` will be called if the execution of either :code:`!one` or :code:`!two` is blocked, regardless by which of the two middlewares. 199 | 200 | .. code-block:: python 201 | :linenos: 202 | :emphasize-lines: 23, 24, 37 203 | 204 | import asyncio 205 | from twitchAPI import Twitch 206 | from twitchAPI.chat import Chat, ChatCommand 207 | from twitchAPI.chat.middleware import UserRestriction, ChannelRestriction 208 | from twitchAPI.oauth import UserAuthenticationStorageHelper 209 | from twitchAPI.types import AuthScope 210 | 211 | 212 | APP_ID = 'your_app_id' 213 | APP_SECRET = 'your_app_secret' 214 | SCOPES = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] 215 | TARGET_CHANNEL = ['your_first_channel', 'your_second_channel'] 216 | 217 | 218 | async def command_one(cmd: ChatCommand): 219 | await cmd.reply('This is the first command!') 220 | 221 | 222 | async def command_two(cmd: ChatCommand): 223 | await cmd.reply('This is the second command!') 224 | 225 | 226 | async def handle_command_blocked(cmd: ChatCommand): 227 | await cmd.reply(f'You are not allowed to use {cmd.name}!') 228 | 229 | 230 | async def run(): 231 | twitch = await Twitch(APP_ID, APP_SECRET) 232 | helper = UserAuthenticationStorageHelper(twitch, SCOPES) 233 | await helper.bind() 234 | chat = await Chat(twitch, initial_channel=TARGET_CHANNEL) 235 | chat.register_command_middleware(UserRestriction(allowed_users=['user1'])) 236 | chat.register_command_middleware(ChannelRestriction(allowed_channel=['your_first_channel'])) 237 | 238 | chat.register_command('one', command_one) 239 | chat.register_command('two', command_two) 240 | chat.default_command_execution_blocked_handler = handle_command_blocked 241 | 242 | chat.start() 243 | try: 244 | input('press Enter to shut down...\n') 245 | except KeyboardInterrupt: 246 | pass 247 | finally: 248 | chat.stop() 249 | await twitch.close() 250 | 251 | 252 | asyncio.run(run()) 253 | 254 | Using a middleware specific handler 255 | ----------------------------------- 256 | 257 | A middleware specific handler can be used to change the response based on which middleware blocked the execution of a command. 258 | Note that this can again be both set for command specific middleware as well as global middleware. 259 | For this example we will only look at global middleware but the method is exactly the same for command specific one. 260 | 261 | To set a middleware specific handler, you have to set :const:`~twitchAPI.chat.middleware.BaseCommandMiddleware.execute_blocked_handler`. 262 | For the preimplemented middleware in this library, you can always pass this in the init of the middleware. 263 | 264 | In the following example we will be responding different based on which middleware blocked the command. 265 | 266 | 267 | .. code-block:: python 268 | :linenos: 269 | :emphasize-lines: 23, 24, 27, 28, 36, 37, 38, 39 270 | 271 | import asyncio 272 | from twitchAPI import Twitch 273 | from twitchAPI.chat import Chat, ChatCommand 274 | from twitchAPI.chat.middleware import UserRestriction, ChannelRestriction 275 | from twitchAPI.oauth import UserAuthenticationStorageHelper 276 | from twitchAPI.types import AuthScope 277 | 278 | 279 | APP_ID = 'your_app_id' 280 | APP_SECRET = 'your_app_secret' 281 | SCOPES = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] 282 | TARGET_CHANNEL = ['your_first_channel', 'your_second_channel'] 283 | 284 | 285 | async def command_one(cmd: ChatCommand): 286 | await cmd.reply('This is the first command!') 287 | 288 | 289 | async def command_two(cmd: ChatCommand): 290 | await cmd.reply('This is the second command!') 291 | 292 | 293 | async def handle_blocked_user(cmd: ChatCommand): 294 | await cmd.reply(f'Only user1 is allowed to use {cmd.name}!') 295 | 296 | 297 | async def handle_blocked_channel(cmd: ChatCommand): 298 | await cmd.reply(f'{cmd.name} can only be used in channel your_first_channel!') 299 | 300 | 301 | async def run(): 302 | twitch = await Twitch(APP_ID, APP_SECRET) 303 | helper = UserAuthenticationStorageHelper(twitch, SCOPES) 304 | await helper.bind() 305 | chat = await Chat(twitch, initial_channel=TARGET_CHANNEL) 306 | chat.register_command_middleware(UserRestriction(allowed_users=['user1'], 307 | execute_blocked_handler=handle_blocked_user)) 308 | chat.register_command_middleware(ChannelRestriction(allowed_channel=['your_first_channel'], 309 | execute_blocked_handler=handle_blocked_channel)) 310 | 311 | chat.register_command('one', command_one) 312 | chat.register_command('two', command_two) 313 | 314 | chat.start() 315 | try: 316 | input('press Enter to shut down...\n') 317 | except KeyboardInterrupt: 318 | pass 319 | finally: 320 | chat.stop() 321 | await twitch.close() 322 | 323 | 324 | asyncio.run(run()) 325 | 326 | 327 | Write your own Middleware 328 | ************************* 329 | 330 | You can also write your own middleware to implement custom logic, you only have to extend the class :const:`~twitchAPI.chat.middleware.BaseCommandMiddleware`. 331 | 332 | In the following example, we will create a middleware which allows the command to execute in 50% of the times its executed. 333 | 334 | .. code-block:: python 335 | 336 | from typing import Callable, Optional, Awaitable 337 | 338 | class MyOwnCoinFlipMiddleware(BaseCommandMiddleware): 339 | 340 | # it is best practice to add this part of the init function to be compatible with the default middlewares 341 | # but you can also leave this out should you know you dont need it 342 | def __init__(self, execute_blocked_handler: Optional[Callable[[ChatCommand], Awaitable[None]]] = None): 343 | self.execute_blocked_handler = execute_blocked_handler 344 | 345 | async def can_execute(cmd: ChatCommand) -> bool: 346 | # add your own logic here, return True if the command should execute and False otherwise 347 | return random.choice([True, False]) 348 | 349 | async def was_executed(cmd: ChatCommand): 350 | # this will be called whenever a command this Middleware is attached to was executed, use this to update your internal state 351 | # since this is a basic example, we do nothing here 352 | pass 353 | 354 | 355 | Now use this middleware as any other: 356 | 357 | .. code-block:: python 358 | 359 | chat.register_command('ban-me', execute_ban_me, command_middleware=[MyOwnCoinFlipMiddleware()]) 360 | 361 | -------------------------------------------------------------------------------- /twitchAPI/eventsub/webhook.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023. Lena "Teekeks" During 2 | """ 3 | EventSub Webhook 4 | ---------------- 5 | 6 | .. note:: EventSub Webhook is targeted at programs which have to subscribe to topics for multiple broadcasters.\n 7 | Should you only need to target a single broadcaster or are building a client side project, look at :doc:`/modules/twitchAPI.eventsub.websocket` 8 | 9 | EventSub lets you listen for events that happen on Twitch. 10 | 11 | The EventSub client runs in its own thread, calling the given callback function whenever an event happens. 12 | 13 | ************ 14 | Requirements 15 | ************ 16 | 17 | .. note:: Please note that Your Endpoint URL has to be HTTPS, has to run on Port 443 and requires a valid, non self signed certificate 18 | This most likely means, that you need a reverse proxy like nginx. You can also hand in a valid ssl context to be used in the constructor. 19 | 20 | In the case that you don't hand in a valid ssl context to the constructor, you can specify any port you want in the constructor and handle the 21 | bridge between this program and your public URL on port 443 via reverse proxy.\n 22 | You can check on whether or not your webhook is publicly reachable by navigating to the URL set in `callback_url`. 23 | You should get a 200 response with the text :code:`pyTwitchAPI eventsub`. 24 | 25 | ******************* 26 | Listening to topics 27 | ******************* 28 | 29 | After you started your EventSub client, you can use the :code:`listen_` prefixed functions to listen to the topics you are interested in. 30 | 31 | Look at :ref:`eventsub-available-topics` to find the topics you are interested in. 32 | 33 | The function you hand in as callback will be called whenever that event happens with the event data as a parameter, 34 | the type of that parameter is also listed in the link above. 35 | 36 | ************ 37 | Code Example 38 | ************ 39 | 40 | .. code-block:: python 41 | 42 | from twitchAPI.twitch import Twitch 43 | from twitchAPI.helper import first 44 | from twitchAPI.eventsub.webhook import EventSubWebhook 45 | from twitchAPI.object.eventsub import ChannelFollowEvent 46 | from twitchAPI.oauth import UserAuthenticator 47 | from twitchAPI.type import AuthScope 48 | import asyncio 49 | 50 | TARGET_USERNAME = 'target_username_here' 51 | EVENTSUB_URL = 'https://url.to.your.webhook.com' 52 | APP_ID = 'your_app_id' 53 | APP_SECRET = 'your_app_secret' 54 | TARGET_SCOPES = [AuthScope.MODERATOR_READ_FOLLOWERS] 55 | 56 | 57 | async def on_follow(data: ChannelFollowEvent): 58 | # our event happened, lets do things with the data we got! 59 | print(f'{data.event.user_name} now follows {data.event.broadcaster_user_name}!') 60 | 61 | 62 | async def eventsub_webhook_example(): 63 | # create the api instance and get the ID of the target user 64 | twitch = await Twitch(APP_ID, APP_SECRET) 65 | user = await first(twitch.get_users(logins=TARGET_USERNAME)) 66 | 67 | # the user has to authenticate once using the bot with our intended scope. 68 | # since we do not need the resulting token after this authentication, we just discard the result we get from authenticate() 69 | # Please read up the UserAuthenticator documentation to get a full view of how this process works 70 | auth = UserAuthenticator(twitch, TARGET_SCOPES) 71 | await auth.authenticate() 72 | 73 | # basic setup, will run on port 8080 and a reverse proxy takes care of the https and certificate 74 | eventsub = EventSubWebhook(EVENTSUB_URL, 8080, twitch) 75 | 76 | # unsubscribe from all old events that might still be there 77 | # this will ensure we have a clean slate 78 | await eventsub.unsubscribe_all() 79 | # start the eventsub client 80 | eventsub.start() 81 | # subscribing to the desired eventsub hook for our user 82 | # the given function (in this example on_follow) will be called every time this event is triggered 83 | # the broadcaster is a moderator in their own channel by default so specifying both as the same works in this example 84 | await eventsub.listen_channel_follow_v2(user.id, user.id, on_follow) 85 | 86 | # eventsub will run in its own process 87 | # so lets just wait for user input before shutting it all down again 88 | try: 89 | input('press Enter to shut down...') 90 | finally: 91 | # stopping both eventsub as well as gracefully closing the connection to the API 92 | await eventsub.stop() 93 | await twitch.close() 94 | print('done') 95 | 96 | 97 | # lets run our example 98 | asyncio.run(eventsub_webhook_example())""" 99 | import asyncio 100 | import hashlib 101 | import hmac 102 | import threading 103 | from functools import partial 104 | from json import JSONDecodeError 105 | from random import choice 106 | from string import ascii_lowercase 107 | from ssl import SSLContext 108 | from time import sleep 109 | from typing import Optional, Union, Callable, Awaitable 110 | import datetime 111 | from collections import deque 112 | 113 | from aiohttp import web, ClientSession 114 | 115 | from twitchAPI.eventsub.base import EventSubBase 116 | from ..twitch import Twitch 117 | from ..helper import done_task_callback 118 | from ..type import TwitchBackendException, EventSubSubscriptionConflict, EventSubSubscriptionError, EventSubSubscriptionTimeout, \ 119 | TwitchAuthorizationException, AuthType 120 | 121 | __all__ = ['EventSubWebhook'] 122 | 123 | 124 | class EventSubWebhook(EventSubBase): 125 | 126 | def __init__(self, 127 | callback_url: str, 128 | port: int, 129 | twitch: Twitch, 130 | ssl_context: Optional[SSLContext] = None, 131 | host_binding: str = '0.0.0.0', 132 | subscription_url: Optional[str] = None, 133 | callback_loop: Optional[asyncio.AbstractEventLoop] = None, 134 | revocation_handler: Optional[Callable[[dict], Awaitable[None]]] = None, 135 | message_deduplication_history_length: int = 50): 136 | """ 137 | :param callback_url: The full URL of the webhook. 138 | :param port: the port on which this webhook should run 139 | :param twitch: a app authenticated instance of :const:`~twitchAPI.twitch.Twitch` 140 | :param ssl_context: optional ssl context to be used |default| :code:`None` 141 | :param host_binding: the host to bind the internal server to |default| :code:`0.0.0.0` 142 | :param subscription_url: Alternative subscription URL, useful for development with the twitch-cli 143 | :param callback_loop: The asyncio eventloop to be used for callbacks. \n 144 | Set this if you or a library you use cares about which asyncio event loop is running the callbacks. 145 | Defaults to the one used by EventSub Webhook. 146 | :param revocation_handler: Optional handler for when subscriptions get revoked. |default| :code:`None` 147 | :param message_deduplication_history_length: The amount of messages being considered for the duplicate message deduplication. |default| :code:`50` 148 | """ 149 | super().__init__(twitch, 'twitchAPI.eventsub.webhook') 150 | self.callback_url: str = callback_url 151 | """The full URL of the webhook.""" 152 | if self.callback_url[-1] == '/': 153 | self.callback_url = self.callback_url[:-1] 154 | self.secret: str = ''.join(choice(ascii_lowercase) for _ in range(20)) 155 | """A random secret string. Set this for added security. |default| :code:`A random 20 character long string`""" 156 | self.wait_for_subscription_confirm: bool = True 157 | """Set this to false if you don't want to wait for a subscription confirm. |default| :code:`True`""" 158 | self.wait_for_subscription_confirm_timeout: int = 30 159 | """Max time in seconds to wait for a subscription confirmation. Only used if ``wait_for_subscription_confirm`` is set to True. 160 | |default| :code:`30`""" 161 | 162 | self._port: int = port 163 | self.subscription_url: Optional[str] = subscription_url 164 | """Alternative subscription URL, useful for development with the twitch-cli""" 165 | if self.subscription_url is not None and self.subscription_url[-1] != '/': 166 | self.subscription_url += '/' 167 | self._callback_loop = callback_loop 168 | self._host: str = host_binding 169 | self.__running = False 170 | self.revokation_handler: Optional[Callable[[dict], Awaitable[None]]] = revocation_handler 171 | """Optional handler for when subscriptions get revoked.""" 172 | self._startup_complete = False 173 | self.unsubscribe_on_stop: bool = True 174 | """Unsubscribe all currently active Webhooks on calling :const:`~twitchAPI.eventsub.EventSub.stop()` |default| :code:`True`""" 175 | 176 | self._closing = False 177 | self.__ssl_context: Optional[SSLContext] = ssl_context 178 | self.__active_webhooks = {} 179 | self.__hook_thread: Union['threading.Thread', None] = None 180 | self.__hook_loop: Union['asyncio.AbstractEventLoop', None] = None 181 | self.__hook_runner: Union['web.AppRunner', None] = None 182 | self._task_callback = partial(done_task_callback, self.logger) 183 | if not self.callback_url.startswith('https'): 184 | raise RuntimeError('HTTPS is required for authenticated webhook.\n' 185 | + 'Either use non authenticated webhook or use a HTTPS proxy!') 186 | self._msg_id_history: deque = deque(maxlen=message_deduplication_history_length) 187 | 188 | async def _unsubscribe_hook(self, topic_id: str) -> bool: 189 | return True 190 | 191 | def __build_runner(self): 192 | hook_app = web.Application() 193 | hook_app.add_routes([web.post('/callback', self.__handle_callback), 194 | web.get('/', self.__handle_default)]) 195 | return web.AppRunner(hook_app) 196 | 197 | def __run_hook(self, runner: 'web.AppRunner'): 198 | self.__hook_runner = runner 199 | self.__hook_loop = asyncio.new_event_loop() 200 | if self._callback_loop is None: 201 | self._callback_loop = self.__hook_loop 202 | asyncio.set_event_loop(self.__hook_loop) 203 | self.__hook_loop.run_until_complete(runner.setup()) 204 | site = web.TCPSite(runner, str(self._host), self._port, ssl_context=self.__ssl_context) 205 | self.__hook_loop.run_until_complete(site.start()) 206 | self.logger.info('started twitch API event sub on port ' + str(self._port)) 207 | self._startup_complete = True 208 | self.__hook_loop.run_until_complete(self._keep_loop_alive()) 209 | 210 | async def _keep_loop_alive(self): 211 | while not self._closing: 212 | await asyncio.sleep(0.1) 213 | 214 | def start(self): 215 | """Starts the EventSub client 216 | 217 | :rtype: None 218 | :raises RuntimeError: if EventSub is already running 219 | """ 220 | if self.__running: 221 | raise RuntimeError('already started') 222 | self.__hook_thread = threading.Thread(target=self.__run_hook, args=(self.__build_runner(),)) 223 | self.__running = True 224 | self._startup_complete = False 225 | self._closing = False 226 | self.__hook_thread.start() 227 | while not self._startup_complete: 228 | sleep(0.1) 229 | 230 | async def stop(self): 231 | """Stops the EventSub client 232 | 233 | This also unsubscribes from all known subscriptions if unsubscribe_on_stop is True 234 | 235 | :rtype: None 236 | :raises RuntimeError: if EventSub is not running 237 | """ 238 | if not self.__running: 239 | raise RuntimeError('EventSubWebhook is not running') 240 | self.logger.debug('shutting down eventsub') 241 | if self.__hook_runner is not None and self.unsubscribe_on_stop: 242 | await self.unsubscribe_all_known() 243 | # ensure all client sessions are closed 244 | await asyncio.sleep(0.25) 245 | self._closing = True 246 | # cleanly shut down the runner 247 | if self.__hook_runner is not None: 248 | await self.__hook_runner.shutdown() 249 | await self.__hook_runner.cleanup() 250 | self.__hook_runner = None 251 | self.__running = False 252 | self.logger.debug('eventsub shut down') 253 | 254 | def _get_transport(self) -> dict: 255 | return { 256 | 'method': 'webhook', 257 | 'callback': f'{self.callback_url}/callback', 258 | 'secret': self.secret 259 | } 260 | 261 | async def _build_request_header(self) -> dict: 262 | token = await self._twitch.get_refreshed_app_token() 263 | if token is None: 264 | raise TwitchAuthorizationException('no Authorization set!') 265 | return { 266 | 'Client-ID': self._twitch.app_id, 267 | 'Content-Type': 'application/json', 268 | 'Authorization': f'Bearer {token}' 269 | } 270 | 271 | async def _subscribe(self, sub_type: str, sub_version: str, condition: dict, callback, event, is_batching_enabled: Optional[bool] = None) -> str: 272 | """"Subscribe to Twitch Topic""" 273 | if not asyncio.iscoroutinefunction(callback): 274 | raise ValueError('callback needs to be a async function which takes one parameter') 275 | self.logger.debug(f'subscribe to {sub_type} version {sub_version} with condition {condition}') 276 | data = { 277 | 'type': sub_type, 278 | 'version': sub_version, 279 | 'condition': condition, 280 | 'transport': self._get_transport() 281 | } 282 | if is_batching_enabled is not None: 283 | data['is_batching_enabled'] = is_batching_enabled 284 | 285 | async with ClientSession(timeout=self._twitch.session_timeout) as session: 286 | sub_base = self.subscription_url if self.subscription_url is not None else self._twitch.base_url 287 | r_data = await self._api_post_request(session, sub_base + 'eventsub/subscriptions', data=data) 288 | result = await r_data.json() 289 | error = result.get('error') 290 | if r_data.status == 500: 291 | raise TwitchBackendException(error) 292 | if error is not None: 293 | if error.lower() == 'conflict': 294 | raise EventSubSubscriptionConflict(result.get('message', '')) 295 | raise EventSubSubscriptionError(result.get('message')) 296 | sub_id = result['data'][0]['id'] 297 | self.logger.debug(f'subscription for {sub_type} version {sub_version} with condition {condition} has id {sub_id}') 298 | self._add_callback(sub_id, callback, event) 299 | if self.wait_for_subscription_confirm: 300 | timeout = datetime.datetime.utcnow() + datetime.timedelta( 301 | seconds=self.wait_for_subscription_confirm_timeout) 302 | while timeout >= datetime.datetime.utcnow(): 303 | if self._callbacks[sub_id]['active']: 304 | return sub_id 305 | await asyncio.sleep(0.01) 306 | self._callbacks.pop(sub_id, None) 307 | raise EventSubSubscriptionTimeout() 308 | return sub_id 309 | 310 | def _target_token(self) -> AuthType: 311 | return AuthType.APP 312 | 313 | async def _verify_signature(self, request: 'web.Request') -> bool: 314 | expected = request.headers['Twitch-Eventsub-Message-Signature'] 315 | hmac_message = request.headers['Twitch-Eventsub-Message-Id'] + \ 316 | request.headers['Twitch-Eventsub-Message-Timestamp'] + await request.text() 317 | sig = 'sha256=' + hmac.new(bytes(self.secret, 'utf-8'), 318 | msg=bytes(hmac_message, 'utf-8'), 319 | digestmod=hashlib.sha256).hexdigest().lower() 320 | return sig == expected 321 | 322 | # noinspection PyUnusedLocal 323 | @staticmethod 324 | async def __handle_default(request: 'web.Request'): 325 | return web.Response(text="pyTwitchAPI EventSub") 326 | 327 | async def __handle_challenge(self, request: 'web.Request', data: dict): 328 | self.logger.debug(f'received challenge for subscription {data.get("subscription", {}).get("id")}') 329 | if not await self._verify_signature(request): 330 | self.logger.warning('message signature is not matching! Discarding message') 331 | return web.Response(status=403) 332 | await self._activate_callback(data.get('subscription', {}).get('id')) 333 | return web.Response(text=data.get('challenge')) 334 | 335 | async def _handle_revokation(self, data): 336 | sub_id: str = data.get('subscription', {}).get('id') 337 | self.logger.debug(f'got revocation of subscription {sub_id} for reason {data.get("subscription").get("status")}') 338 | if sub_id not in self._callbacks.keys(): 339 | self.logger.warning(f'unknown subscription {sub_id} got revoked. ignore') 340 | return 341 | self._callbacks.pop(sub_id) 342 | if self.revokation_handler is not None and self._callback_loop is not None: 343 | t = self._callback_loop.create_task(self.revokation_handler(data)) #type: ignore 344 | t.add_done_callback(self._task_callback) 345 | 346 | async def __handle_callback(self, request: 'web.Request'): 347 | try: 348 | data: dict = await request.json() 349 | except JSONDecodeError: 350 | self.logger.error('got request with malformed body! Discarding message') 351 | return web.Response(status=400) 352 | if data.get('challenge') is not None: 353 | return await self.__handle_challenge(request, data) 354 | sub_id = data.get('subscription', {}).get('id') 355 | callback = self._callbacks.get(sub_id) 356 | if callback is None: 357 | self.logger.error(f'received event for unknown subscription with ID {sub_id}') 358 | else: 359 | if not await self._verify_signature(request): 360 | self.logger.warning('message signature is not matching! Discarding message') 361 | return web.Response(status=403) 362 | msg_type = request.headers['Twitch-Eventsub-Message-Type'] 363 | if msg_type.lower() == 'revocation': 364 | await self._handle_revokation(data) 365 | else: 366 | msg_id = request.headers.get('Twitch-Eventsub-Message-Id') 367 | if msg_id is not None and msg_id in self._msg_id_history: 368 | self.logger.warning(f'got message with duplicate id {msg_id}! Discarding message') 369 | else: 370 | self._msg_id_history.append(msg_id) 371 | data['metadata'] = { 372 | 'message_id': msg_id, 373 | 'message_type': msg_type, 374 | 'message_timestamp': request.headers['Twitch-Eventsub-Message-Timestamp'], 375 | 'subscription_type': request.headers['Twitch-Eventsub-Subscription-Type'], 376 | 'subscription_version': request.headers['Twitch-Eventsub-Subscription-Version'], 377 | } 378 | dat = callback['event'](**data) 379 | if self._callback_loop is not None: 380 | t = self._callback_loop.create_task(callback['callback'](dat)) 381 | t.add_done_callback(self._task_callback) 382 | return web.Response(status=200) 383 | -------------------------------------------------------------------------------- /twitchAPI/eventsub/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023. Lena "Teekeks" During 2 | """ 3 | EventSub 4 | -------- 5 | 6 | EventSub lets you listen for events that happen on Twitch. 7 | 8 | All available EventSub clients runs in their own thread, calling the given callback function whenever an event happens. 9 | 10 | Look at :ref:`eventsub-available-topics` to find the topics you are interested in. 11 | 12 | Available Transports 13 | ==================== 14 | 15 | EventSub is available with different types of transports, used for different applications. 16 | 17 | .. list-table:: 18 | :header-rows: 1 19 | 20 | * - Transport Method 21 | - Use Case 22 | - Auth Type 23 | * - :doc:`twitchAPI.eventsub.webhook` 24 | - Server / Multi User 25 | - App Authentication 26 | * - :doc:`twitchAPI.eventsub.websocket` 27 | - Client / Single User 28 | - User Authentication 29 | 30 | 31 | .. _eventsub-available-topics: 32 | 33 | Available Topics and Callback Payloads 34 | ====================================== 35 | 36 | List of available EventSub Topics. 37 | 38 | The Callback Payload is the type of the parameter passed to the callback function you specified in :const:`listen_`. 39 | 40 | .. list-table:: 41 | :header-rows: 1 42 | 43 | * - Topic 44 | - Subscription Function & Callback Payload 45 | - Description 46 | * - **Channel Update** v1 47 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_update()` |br| 48 | Payload: :const:`~twitchAPI.object.eventsub.ChannelUpdateEvent` 49 | - A broadcaster updates their channel properties e.g., category, title, mature flag, broadcast, or language. 50 | * - **Channel Update** v2 51 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_update_v2()` |br| 52 | Payload: :const:`~twitchAPI.object.eventsub.ChannelUpdateEvent` 53 | - A broadcaster updates their channel properties e.g., category, title, content classification labels, broadcast, or language. 54 | * - **Channel Follow** v2 55 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_follow_v2()` |br| 56 | Payload: :const:`~twitchAPI.object.eventsub.ChannelFollowEvent` 57 | - A specified channel receives a follow. 58 | * - **Channel Subscribe** 59 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_subscribe()` |br| 60 | Payload: :const:`~twitchAPI.object.eventsub.ChannelSubscribeEvent` 61 | - A notification when a specified channel receives a subscriber. This does not include resubscribes. 62 | * - **Channel Subscription End** 63 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_subscription_end()` |br| 64 | Payload: :const:`~twitchAPI.object.eventsub.ChannelSubscriptionEndEvent` 65 | - A notification when a subscription to the specified channel ends. 66 | * - **Channel Subscription Gift** 67 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_subscription_gift()` |br| 68 | Payload: :const:`~twitchAPI.object.eventsub.ChannelSubscriptionGiftEvent` 69 | - A notification when a viewer gives a gift subscription to one or more users in the specified channel. 70 | * - **Channel Subscription Message** 71 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_subscription_message()` |br| 72 | Payload: :const:`~twitchAPI.object.eventsub.ChannelSubscriptionMessageEvent` 73 | - A notification when a user sends a resubscription chat message in a specific channel. 74 | * - **Channel Cheer** 75 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_cheer()` |br| 76 | Payload: :const:`~twitchAPI.object.eventsub.ChannelCheerEvent` 77 | - A user cheers on the specified channel. 78 | * - **Channel Raid** 79 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_raid()` |br| 80 | Payload: :const:`~twitchAPI.object.eventsub.ChannelRaidEvent` 81 | - A broadcaster raids another broadcaster’s channel. 82 | * - **Channel Ban** 83 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_ban()` |br| 84 | Payload: :const:`~twitchAPI.object.eventsub.ChannelBanEvent` 85 | - A viewer is banned from the specified channel. 86 | * - **Channel Unban** 87 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_unban()` |br| 88 | Payload: :const:`~twitchAPI.object.eventsub.ChannelUnbanEvent` 89 | - A viewer is unbanned from the specified channel. 90 | * - **Channel Moderator Add** 91 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_moderator_add()` |br| 92 | Payload: :const:`~twitchAPI.object.eventsub.ChannelModeratorAddEvent` 93 | - Moderator privileges were added to a user on a specified channel. 94 | * - **Channel Moderator Remove** 95 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_moderator_remove()` |br| 96 | Payload: :const:`~twitchAPI.object.eventsub.ChannelModeratorRemoveEvent` 97 | - Moderator privileges were removed from a user on a specified channel. 98 | * - **Channel Points Custom Reward Add** 99 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_points_custom_reward_add()` |br| 100 | Payload: :const:`~twitchAPI.object.eventsub.ChannelPointsCustomRewardAddEvent` 101 | - A custom channel points reward has been created for the specified channel. 102 | * - **Channel Points Custom Reward Update** 103 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_points_custom_reward_update()` |br| 104 | Payload: :const:`~twitchAPI.object.eventsub.ChannelPointsCustomRewardUpdateEvent` 105 | - A custom channel points reward has been updated for the specified channel. 106 | * - **Channel Points Custom Reward Remove** 107 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_points_custom_reward_remove()` |br| 108 | Payload: :const:`~twitchAPI.object.eventsub.ChannelPointsCustomRewardRemoveEvent` 109 | - A custom channel points reward has been removed from the specified channel. 110 | * - **Channel Points Custom Reward Redemption Add** 111 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_points_custom_reward_redemption_add()` |br| 112 | Payload: :const:`~twitchAPI.object.eventsub.ChannelPointsCustomRewardRedemptionAddEvent` 113 | - A viewer has redeemed a custom channel points reward on the specified channel. 114 | * - **Channel Points Custom Reward Redemption Update** 115 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_points_custom_reward_redemption_update()` |br| 116 | Payload: :const:`~twitchAPI.object.eventsub.ChannelPointsCustomRewardRedemptionUpdateEvent` 117 | - A redemption of a channel points custom reward has been updated for the specified channel. 118 | * - **Channel Poll Begin** 119 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_poll_begin()` |br| 120 | Payload: :const:`~twitchAPI.object.eventsub.ChannelPollBeginEvent` 121 | - A poll started on a specified channel. 122 | * - **Channel Poll Progress** 123 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_poll_progress()` |br| 124 | Payload: :const:`~twitchAPI.object.eventsub.ChannelPollProgressEvent` 125 | - Users respond to a poll on a specified channel. 126 | * - **Channel Poll End** 127 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_poll_end()` |br| 128 | Payload: :const:`~twitchAPI.object.eventsub.ChannelPollEndEvent` 129 | - A poll ended on a specified channel. 130 | * - **Channel Prediction Begin** 131 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_prediction_begin()` |br| 132 | Payload: :const:`~twitchAPI.object.eventsub.ChannelPredictionEvent` 133 | - A Prediction started on a specified channel. 134 | * - **Channel Prediction Progress** 135 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_prediction_progress()` |br| 136 | Payload: :const:`~twitchAPI.object.eventsub.ChannelPredictionEvent` 137 | - Users participated in a Prediction on a specified channel. 138 | * - **Channel Prediction Lock** 139 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_prediction_lock()` |br| 140 | Payload: :const:`~twitchAPI.object.eventsub.ChannelPredictionEvent` 141 | - A Prediction was locked on a specified channel. 142 | * - **Channel Prediction End** 143 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_prediction_end()` |br| 144 | Payload: :const:`~twitchAPI.object.eventsub.ChannelPredictionEndEvent` 145 | - A Prediction ended on a specified channel. 146 | * - **Drop Entitlement Grant** 147 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_drop_entitlement_grant()` |br| 148 | Payload: :const:`~twitchAPI.object.eventsub.DropEntitlementGrantEvent` 149 | - An entitlement for a Drop is granted to a user. 150 | * - **Extension Bits Transaction Create** 151 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_extension_bits_transaction_create()` |br| 152 | Payload: :const:`~twitchAPI.object.eventsub.ExtensionBitsTransactionCreateEvent` 153 | - A Bits transaction occurred for a specified Twitch Extension. 154 | * - **Goal Begin** 155 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_goal_begin()` |br| 156 | Payload: :const:`~twitchAPI.object.eventsub.GoalEvent` 157 | - A goal begins on the specified channel. 158 | * - **Goal Progress** 159 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_goal_progress()` |br| 160 | Payload: :const:`~twitchAPI.object.eventsub.GoalEvent` 161 | - A goal makes progress on the specified channel. 162 | * - **Goal End** 163 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_goal_end()` |br| 164 | Payload: :const:`~twitchAPI.object.eventsub.GoalEvent` 165 | - A goal ends on the specified channel. 166 | * - **Hype Train Begin** 167 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_hype_train_begin()` |br| 168 | Payload: :const:`~twitchAPI.object.eventsub.HypeTrainEvent` 169 | - A Hype Train begins on the specified channel. 170 | * - **Hype Train Progress** 171 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_hype_train_progress()` |br| 172 | Payload: :const:`~twitchAPI.object.eventsub.HypeTrainEvent` 173 | - A Hype Train makes progress on the specified channel. 174 | * - **Hype Train End** 175 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_hype_train_end()` |br| 176 | Payload: :const:`~twitchAPI.object.eventsub.HypeTrainEvent` 177 | - A Hype Train ends on the specified channel. 178 | * - **Stream Online** 179 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_stream_online()` |br| 180 | Payload: :const:`~twitchAPI.object.eventsub.StreamOnlineEvent` 181 | - The specified broadcaster starts a stream. 182 | * - **Stream Offline** 183 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_stream_offline()` |br| 184 | Payload: :const:`~twitchAPI.object.eventsub.StreamOfflineEvent` 185 | - The specified broadcaster stops a stream. 186 | * - **User Authorization Grant** 187 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_user_authorization_grant()` |br| 188 | Payload: :const:`~twitchAPI.object.eventsub.UserAuthorizationGrantEvent` 189 | - A user’s authorization has been granted to your client id. 190 | * - **User Authorization Revoke** 191 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_user_authorization_revoke()` |br| 192 | Payload: :const:`~twitchAPI.object.eventsub.UserAuthorizationRevokeEvent` 193 | - A user’s authorization has been revoked for your client id. 194 | * - **User Update** 195 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_user_update()` |br| 196 | Payload: :const:`~twitchAPI.object.eventsub.UserUpdateEvent` 197 | - A user has updated their account. 198 | * - **Channel Shield Mode Begin** 199 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_shield_mode_begin()` |br| 200 | Payload: :const:`~twitchAPI.object.eventsub.ShieldModeEvent` 201 | - Sends a notification when the broadcaster activates Shield Mode. 202 | * - **Channel Shield Mode End** 203 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_shield_mode_end()` |br| 204 | Payload: :const:`~twitchAPI.object.eventsub.ShieldModeEvent` 205 | - Sends a notification when the broadcaster deactivates Shield Mode. 206 | * - **Channel Charity Campaign Start** 207 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_charity_campaign_start()` |br| 208 | Payload: :const:`~twitchAPI.object.eventsub.CharityCampaignStartEvent` 209 | - Sends a notification when the broadcaster starts a charity campaign. 210 | * - **Channel Charity Campaign Progress** 211 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_charity_campaign_progress()` |br| 212 | Payload: :const:`~twitchAPI.object.eventsub.CharityCampaignProgressEvent` 213 | - Sends notifications when progress is made towards the campaign’s goal or when the broadcaster changes the fundraising goal. 214 | * - **Channel Charity Campaign Stop** 215 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_charity_campaign_stop()` |br| 216 | Payload: :const:`~twitchAPI.object.eventsub.CharityCampaignStopEvent` 217 | - Sends a notification when the broadcaster stops a charity campaign. 218 | * - **Channel Charity Campaign Donate** 219 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_charity_campaign_donate()` |br| 220 | Payload: :const:`~twitchAPI.object.eventsub.CharityDonationEvent` 221 | - Sends a notification when a user donates to the broadcaster’s charity campaign. 222 | * - **Channel Shoutout Create** 223 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_shoutout_create()` |br| 224 | Payload: :const:`~twitchAPI.object.eventsub.ChannelShoutoutCreateEvent` 225 | - Sends a notification when the specified broadcaster sends a Shoutout. 226 | * - **Channel Shoutout Receive** 227 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_shoutout_receive()` |br| 228 | Payload: :const:`~twitchAPI.object.eventsub.ChannelShoutoutReceiveEvent` 229 | - Sends a notification when the specified broadcaster receives a Shoutout. 230 | * - **Channel Chat Clear** 231 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_clear()` |br| 232 | Payload: :const:`~twitchAPI.object.eventsub.ChannelChatClearEvent` 233 | - A moderator or bot has cleared all messages from the chat room. 234 | * - **Channel Chat Clear User Messages** 235 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_clear_user_messages()` |br| 236 | Payload: :const:`~twitchAPI.object.eventsub.ChannelChatClearUserMessagesEvent` 237 | - A moderator or bot has cleared all messages from a specific user. 238 | * - **Channel Chat Message Delete** 239 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_message_delete()` |br| 240 | Payload: :const:`~twitchAPI.object.eventsub.ChannelChatMessageDeleteEvent` 241 | - A moderator has removed a specific message. 242 | * - **Channel Chat Notification** 243 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_notification()` |br| 244 | Payload: :const:`~twitchAPI.object.eventsub.ChannelChatNotificationEvent` 245 | - A notification for when an event that appears in chat has occurred. 246 | * - **Channel Chat Message** 247 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_message()` |br| 248 | Payload: :const:`~twitchAPI.object.eventsub.ChannelChatMessageEvent` 249 | - Any user sends a message to a specific chat room. 250 | * - **Channel Ad Break Begin** 251 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_ad_break_begin()` |br| 252 | Payload: :const:`~twitchAPI.object.eventsub.ChannelAdBreakBeginEvent` 253 | - A midroll commercial break has started running. 254 | * - **Channel Chat Settings Update** 255 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_settings_update()` |br| 256 | Payload: :const:`~twitchAPI.object.eventsub.ChannelChatSettingsUpdateEvent` 257 | - A notification for when a broadcaster’s chat settings are updated. 258 | * - **Whisper Received** 259 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_user_whisper_message()` |br| 260 | Payload: :const:`~twitchAPI.object.eventsub.UserWhisperMessageEvent` 261 | - A user receives a whisper. 262 | * - **Channel Points Automatic Reward Redemption** v1 263 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_points_automatic_reward_redemption_add()` |br| 264 | Payload: :const:`~twitchAPI.object.eventsub.ChannelPointsAutomaticRewardRedemptionAddEvent` 265 | - A viewer has redeemed an automatic channel points reward on the specified channel. 266 | * - **Channel Points Automatic Reward Redemption** v2 267 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_points_automatic_reward_redemption_add_v2()` |br| 268 | Payload: :const:`~twitchAPI.object.eventsub.ChannelPointsAutomaticRewardRedemptionAdd2Event` 269 | - A viewer has redeemed an automatic channel points reward on the specified channel. 270 | * - **Channel VIP Add** 271 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_vip_add()` |br| 272 | Payload: :const:`~twitchAPI.object.eventsub.ChannelVIPAddEvent` 273 | - A VIP is added to the channel. 274 | * - **Channel VIP Remove** 275 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_vip_remove()` |br| 276 | Payload: :const:`~twitchAPI.object.eventsub.ChannelVIPRemoveEvent` 277 | - A VIP is removed from the channel. 278 | * - **Channel Unban Request Create** 279 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_unban_request_create()` |br| 280 | Payload: :const:`~twitchAPI.object.eventsub.ChannelUnbanRequestCreateEvent` 281 | - A user creates an unban request. 282 | * - **Channel Unban Request Resolve** 283 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_unban_request_resolve()` |br| 284 | Payload: :const:`~twitchAPI.object.eventsub.ChannelUnbanRequestResolveEvent` 285 | - An unban request has been resolved. 286 | * - **Channel Suspicious User Message** 287 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_suspicious_user_message()` |br| 288 | Payload: :const:`~twitchAPI.object.eventsub.ChannelSuspiciousUserMessageEvent` 289 | - A chat message has been sent by a suspicious user. 290 | * - **Channel Suspicious User Update** 291 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_suspicious_user_update()` |br| 292 | Payload: :const:`~twitchAPI.object.eventsub.ChannelSuspiciousUserUpdateEvent` 293 | - A suspicious user has been updated. 294 | * - **Channel Moderate** v2 295 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_moderate()` |br| 296 | Payload: :const:`~twitchAPI.object.eventsub.ChannelModerateEvent` 297 | - A moderator performs a moderation action in a channel. Includes warnings. 298 | * - **Channel Warning Acknowledgement** 299 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_warning_acknowledge()` |br| 300 | Payload: :const:`~twitchAPI.object.eventsub.ChannelWarningAcknowledgeEvent` 301 | - A user awknowledges a warning. Broadcasters and moderators can see the warning’s details. 302 | * - **Channel Warning Send** 303 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_warning_send()` |br| 304 | Payload: :const:`~twitchAPI.object.eventsub.ChannelWarningSendEvent` 305 | - A user is sent a warning. Broadcasters and moderators can see the warning’s details. 306 | * - **Automod Message Hold** 307 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_automod_message_hold()` |br| 308 | Payload: :const:`~twitchAPI.object.eventsub.AutomodMessageHoldEvent` 309 | - A user is notified if a message is caught by automod for review. 310 | * - **Automod Message Update** 311 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_automod_message_update()` |br| 312 | Payload: :const:`~twitchAPI.object.eventsub.AutomodMessageUpdateEvent` 313 | - A message in the automod queue had its status changed. 314 | * - **Automod Settings Update** 315 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_automod_settings_update()` |br| 316 | Payload: :const:`~twitchAPI.object.eventsub.AutomodSettingsUpdateEvent` 317 | - A notification is sent when a broadcaster’s automod settings are updated. 318 | * - **Automod Terms Update** 319 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_automod_terms_update()` |br| 320 | Payload: :const:`~twitchAPI.object.eventsub.AutomodTermsUpdateEvent` 321 | - A notification is sent when a broadcaster’s automod terms are updated. Changes to private terms are not sent. 322 | * - **Channel Chat User Message Hold** 323 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_user_message_hold()` |br| 324 | Payload: :const:`~twitchAPI.object.eventsub.ChannelChatUserMessageHoldEvent` 325 | - A user is notified if their message is caught by automod. 326 | * - **Channel Chat User Message Update** 327 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_user_message_update()` |br| 328 | Payload: :const:`~twitchAPI.object.eventsub.ChannelChatUserMessageUpdateEvent` 329 | - A user is notified if their message’s automod status is updated. 330 | * - **Channel Shared Chat Session Begin** 331 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_shared_chat_begin()` |br| 332 | Payload: :const:`~twitchAPI.object.eventsub.ChannelSharedChatBeginEvent` 333 | - A notification when a channel becomes active in an active shared chat session. 334 | * - **Channel Shared Chat Session Update** 335 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_shared_chat_update()` |br| 336 | Payload: :const:`~twitchAPI.object.eventsub.ChannelSharedChatUpdateEvent` 337 | - A notification when the active shared chat session the channel is in changes. 338 | * - **Channel Shared Chat Session End** 339 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_shared_chat_end()` |br| 340 | Payload: :const:`~twitchAPI.object.eventsub.ChannelSharedChatEndEvent` 341 | - A notification when a channel leaves a shared chat session or the session ends. 342 | * - **Channel Bits Use** 343 | - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_bits_use()` |br| 344 | Payload: :const:`~twitchAPI.object.eventsub.ChannelBitsUseEvent` 345 | - A notification is sent whenever Bits are used on a channel. 346 | """ 347 | -------------------------------------------------------------------------------- /twitchAPI/eventsub/websocket.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023. Lena "Teekeks" During 2 | """ 3 | EventSub Websocket 4 | ------------------ 5 | 6 | .. note:: EventSub Websocket is targeted at programs which have to subscribe to topics for just a single broadcaster.\n 7 | Should you need to target multiple broadcasters or are building a server side project, look at :doc:`/modules/twitchAPI.eventsub.webhook` 8 | 9 | EventSub lets you listen for events that happen on Twitch. 10 | 11 | The EventSub client runs in its own thread, calling the given callback function whenever an event happens. 12 | 13 | ******************* 14 | Listening to topics 15 | ******************* 16 | 17 | After you started your EventSub client, you can use the :code:`listen_` prefixed functions to listen to the topics you are interested in. 18 | 19 | Look at :ref:`eventsub-available-topics` to find the topics you are interested in. 20 | 21 | The function you hand in as callback will be called whenever that event happens with the event data as a parameter, 22 | the type of that parameter is also listed in the link above. 23 | 24 | ************ 25 | Code Example 26 | ************ 27 | 28 | .. code-block:: python 29 | 30 | from twitchAPI.helper import first 31 | from twitchAPI.twitch import Twitch 32 | from twitchAPI.oauth import UserAuthenticationStorageHelper 33 | from twitchAPI.object.eventsub import ChannelFollowEvent 34 | from twitchAPI.eventsub.websocket import EventSubWebsocket 35 | from twitchAPI.type import AuthScope 36 | import asyncio 37 | 38 | APP_ID = 'your_app_id' 39 | APP_SECRET = 'your_app_secret' 40 | TARGET_SCOPES = [AuthScope.MODERATOR_READ_FOLLOWERS] 41 | 42 | 43 | async def on_follow(data: ChannelFollowEvent): 44 | # our event happened, lets do things with the data we got! 45 | print(f'{data.event.user_name} now follows {data.event.broadcaster_user_name}!') 46 | 47 | 48 | async def run(): 49 | # create the api instance and get user auth either from storage or website 50 | twitch = await Twitch(APP_ID, APP_SECRET) 51 | helper = UserAuthenticationStorageHelper(twitch, TARGET_SCOPES) 52 | await helper.bind() 53 | 54 | # get the currently logged in user 55 | user = await first(twitch.get_users()) 56 | 57 | # create eventsub websocket instance and start the client. 58 | eventsub = EventSubWebsocket(twitch) 59 | eventsub.start() 60 | # subscribing to the desired eventsub hook for our user 61 | # the given function (in this example on_follow) will be called every time this event is triggered 62 | # the broadcaster is a moderator in their own channel by default so specifying both as the same works in this example 63 | # We have to subscribe to the first topic within 10 seconds of eventsub.start() to not be disconnected. 64 | await eventsub.listen_channel_follow_v2(user.id, user.id, on_follow) 65 | 66 | # eventsub will run in its own process 67 | # so lets just wait for user input before shutting it all down again 68 | try: 69 | input('press Enter to shut down...') 70 | except KeyboardInterrupt: 71 | pass 72 | finally: 73 | # stopping both eventsub as well as gracefully closing the connection to the API 74 | await eventsub.stop() 75 | await twitch.close() 76 | 77 | 78 | asyncio.run(run()) 79 | """ 80 | import asyncio 81 | import datetime 82 | import json 83 | import threading 84 | from asyncio import CancelledError 85 | from dataclasses import dataclass 86 | from functools import partial 87 | from time import sleep 88 | from typing import Optional, List, Dict, Callable, Awaitable 89 | 90 | import aiohttp 91 | from aiohttp import ClientSession, WSMessage, ClientWebSocketResponse 92 | from collections import deque 93 | 94 | from .base import EventSubBase 95 | 96 | 97 | __all__ = ['EventSubWebsocket'] 98 | 99 | from twitchAPI.twitch import Twitch 100 | from ..helper import TWITCH_EVENT_SUB_WEBSOCKET_URL, done_task_callback 101 | from ..type import AuthType, UnauthorizedException, TwitchBackendException, EventSubSubscriptionConflict, EventSubSubscriptionError, \ 102 | TwitchAuthorizationException 103 | 104 | 105 | @dataclass 106 | class Session: 107 | id: str 108 | keepalive_timeout_seconds: int 109 | status: str 110 | reconnect_url: str 111 | 112 | @classmethod 113 | def from_twitch(cls, data: dict): 114 | return cls( 115 | id=data.get('id'), 116 | keepalive_timeout_seconds=data.get('keepalive_timeout_seconds'), 117 | status=data.get('status'), 118 | reconnect_url=data.get('reconnect_url'), 119 | ) 120 | 121 | 122 | @dataclass 123 | class Reconnect: 124 | session: Session 125 | connection: ClientWebSocketResponse 126 | 127 | 128 | class EventSubWebsocket(EventSubBase): 129 | _reconnect: Optional[Reconnect] = None 130 | 131 | def __init__(self, 132 | twitch: Twitch, 133 | connection_url: Optional[str] = None, 134 | subscription_url: Optional[str] = None, 135 | callback_loop: Optional[asyncio.AbstractEventLoop] = None, 136 | revocation_handler: Optional[Callable[[dict], Awaitable[None]]] = None, 137 | message_deduplication_history_length: int = 50): 138 | """ 139 | :param twitch: The Twitch instance to be used 140 | :param connection_url: Alternative connection URL, useful for development with the twitch-cli 141 | :param subscription_url: Alternative subscription URL, useful for development with the twitch-cli 142 | :param callback_loop: The asyncio eventloop to be used for callbacks. \n 143 | Set this if you or a library you use cares about which asyncio event loop is running the callbacks. 144 | Defaults to the one used by EventSub Websocket. 145 | :param revocation_handler: Optional handler for when subscriptions get revoked. |default| :code:`None` 146 | :param message_deduplication_history_length: The amount of messages being considered for the duplicate message deduplication. |default| :code:`50` 147 | """ 148 | super().__init__(twitch, 'twitchAPI.eventsub.websocket') 149 | self.subscription_url: Optional[str] = subscription_url 150 | """The URL where subscriptions are being sent to. Defaults to :const:`~twitchAPI.helper.TWITCH_API_BASE_URL`""" 151 | if self.subscription_url is not None and self.subscription_url[-1] != '/': 152 | self.subscription_url += '/' 153 | self.connection_url: str = connection_url if connection_url is not None else TWITCH_EVENT_SUB_WEBSOCKET_URL 154 | """The URL where the websocket connects to. Defaults to :const:`~twitchAPI.helper.TWITCH_EVENT_SUB_WEBSOCKET_URL`""" 155 | self.active_session: Optional[Session] = None 156 | """The currently used session""" 157 | self._running: bool = False 158 | self._socket_thread = None 159 | self._startup_complete: bool = False 160 | self._socket_loop = None 161 | self._ready: bool = False 162 | self._closing: bool = False 163 | self._connection = None 164 | self._session = None 165 | self._callback_loop = callback_loop 166 | self._is_reconnecting: bool = False 167 | self._active_subscriptions = {} 168 | self._msg_id_history: deque = deque(maxlen=message_deduplication_history_length) 169 | self.revokation_handler: Optional[Callable[[dict], Awaitable[None]]] = revocation_handler 170 | """Optional handler for when subscriptions get revoked.""" 171 | self._task_callback = partial(done_task_callback, self.logger) 172 | self._reconnect_timeout: Optional[datetime.datetime] = None 173 | self.reconnect_delay_steps: List[int] = [0, 1, 2, 4, 8, 16, 32, 64, 128] 174 | """Time in seconds between reconnect attempts""" 175 | 176 | def start(self): 177 | """Starts the EventSub client 178 | 179 | :raises RuntimeError: If EventSub is already running 180 | :raises ~twitchAPI.type.UnauthorizedException: If Twitch instance is missing user authentication 181 | """ 182 | self.logger.debug('starting websocket EventSub...') 183 | if self._running: 184 | raise RuntimeError('EventSubWebsocket is already started!') 185 | if not self._twitch.has_required_auth(AuthType.USER, []): 186 | raise UnauthorizedException('Twitch needs user authentication') 187 | self._startup_complete = False 188 | self._ready = False 189 | self._closing = False 190 | self._socket_thread = threading.Thread(target=self._run_socket) 191 | self._running = True 192 | self._active_subscriptions = {} 193 | self._socket_thread.start() 194 | while not self._startup_complete: 195 | sleep(0.01) 196 | self.logger.debug('EventSubWebsocket started up!') 197 | 198 | async def stop(self): 199 | """Stops the EventSub client 200 | 201 | :raises RuntimeError: If EventSub is not running 202 | """ 203 | if not self._running: 204 | raise RuntimeError('EventSubWebsocket is not running') 205 | self.logger.debug('stopping websocket EventSub...') 206 | self._startup_complete = False 207 | self._running = False 208 | self._ready = False 209 | if self._socket_loop is not None: 210 | f = asyncio.run_coroutine_threadsafe(self._stop(), self._socket_loop) 211 | f.result() 212 | 213 | def _get_transport(self) -> dict: 214 | return { 215 | 'method': 'websocket', 216 | 'session_id': self.active_session.id 217 | } 218 | 219 | async def _subscribe(self, sub_type: str, sub_version: str, condition: dict, callback, event, is_batching_enabled: Optional[bool] = None) -> str: 220 | if not asyncio.iscoroutinefunction(callback): 221 | raise ValueError('callback needs to be a async function which takes one parameter') 222 | self.logger.debug(f'subscribe to {sub_type} version {sub_version} with condition {condition}') 223 | data = { 224 | 'type': sub_type, 225 | 'version': sub_version, 226 | 'condition': condition, 227 | 'transport': self._get_transport() 228 | } 229 | if is_batching_enabled is not None: 230 | data['is_batching_enabled'] = is_batching_enabled 231 | async with ClientSession(timeout=self._twitch.session_timeout) as session: 232 | sub_base = self.subscription_url if self.subscription_url is not None else self._twitch.base_url 233 | r_data = await self._api_post_request(session, sub_base + 'eventsub/subscriptions', data=data) 234 | result = await r_data.json() 235 | error = result.get('error') 236 | if r_data.status == 500: 237 | raise TwitchBackendException(error) 238 | if error is not None: 239 | if error.lower() == 'conflict': 240 | raise EventSubSubscriptionConflict(result.get('message', '')) 241 | raise EventSubSubscriptionError(result.get('message')) 242 | sub_id = result['data'][0]['id'] 243 | self.logger.debug(f'subscription for {sub_type} version {sub_version} with condition {condition} has id {sub_id}') 244 | self._add_callback(sub_id, callback, event) 245 | self._callbacks[sub_id]['active'] = True 246 | self._active_subscriptions[sub_id] = { 247 | 'sub_type': sub_type, 248 | 'sub_version': sub_version, 249 | 'condition': condition, 250 | 'callback': callback, 251 | 'event': event 252 | } 253 | return sub_id 254 | 255 | def _target_token(self) -> AuthType: 256 | return AuthType.USER 257 | 258 | async def _connect(self, is_startup: bool = False): 259 | if is_startup: 260 | self.logger.debug(f'connecting to {self.connection_url}...') 261 | else: 262 | self._is_reconnecting = True 263 | self.logger.debug(f'reconnecting using {self.connection_url}...') 264 | self._reconnect_timeout = None 265 | if self._connection is not None and not self._connection.closed: 266 | await self._connection.close() 267 | while not self._connection.closed: 268 | await asyncio.sleep(0.1) 269 | retry = 0 270 | need_retry = True 271 | if self._session is None: 272 | self._session = aiohttp.ClientSession(timeout=self._twitch.session_timeout) 273 | while need_retry and retry < len(self.reconnect_delay_steps): 274 | need_retry = False 275 | try: 276 | self._connection = await self._session.ws_connect(self.connection_url) 277 | except Exception: 278 | self.logger.warning(f'connection attempt failed, retry in {self.reconnect_delay_steps[retry]} seconds...') 279 | await asyncio.sleep(self.reconnect_delay_steps[retry]) 280 | retry += 1 281 | need_retry = True 282 | if retry >= len(self.reconnect_delay_steps): 283 | raise TwitchBackendException(f'can\'t connect to EventSub websocket {self.connection_url}') 284 | 285 | def _run_socket(self): 286 | self._socket_loop = asyncio.new_event_loop() 287 | if self._callback_loop is None: 288 | self._callback_loop = self._socket_loop 289 | asyncio.set_event_loop(self._socket_loop) 290 | 291 | self._socket_loop.run_until_complete(self._connect(is_startup=True)) 292 | 293 | self._tasks = [ 294 | asyncio.ensure_future(self._task_receive(), loop=self._socket_loop), 295 | asyncio.ensure_future(self._task_reconnect_handler(), loop=self._socket_loop) 296 | ] 297 | self._socket_loop.run_until_complete(self._keep_loop_alive()) 298 | 299 | async def _stop(self): 300 | await self._connection.close() 301 | await self._session.close() 302 | await asyncio.sleep(0.25) 303 | self._connection = None 304 | self._session = None 305 | self._closing = True 306 | 307 | async def _keep_loop_alive(self): 308 | while not self._closing: 309 | await asyncio.sleep(0.1) 310 | 311 | async def _task_reconnect_handler(self): 312 | try: 313 | while not self._closing: 314 | await asyncio.sleep(0.1) 315 | if self._reconnect_timeout is None: 316 | continue 317 | if self._reconnect_timeout <= datetime.datetime.now(): 318 | self.logger.warning('keepalive missed, connection lost. reconnecting...') 319 | self._reconnect_timeout = None 320 | await self._connect(is_startup=False) 321 | except CancelledError: 322 | return 323 | 324 | async def _task_receive(self): 325 | handler: Dict[str, Callable] = { 326 | 'session_welcome': self._handle_welcome, 327 | 'session_keepalive': self._handle_keepalive, 328 | 'notification': self._handle_notification, 329 | 'session_reconnect': self._handle_reconnect, 330 | 'revocation': self._handle_revocation 331 | } 332 | try: 333 | while not self._closing: 334 | if self._connection.closed: 335 | await asyncio.sleep(0.01) 336 | continue 337 | message: WSMessage = await self._connection.receive() 338 | if message.type == aiohttp.WSMsgType.TEXT: 339 | data = json.loads(message.data) 340 | _type = data.get('metadata', {}).get('message_type') 341 | _handler = handler.get(_type) 342 | if _handler is not None: 343 | asyncio.ensure_future(_handler(data)) 344 | # debug 345 | else: 346 | self.logger.warning(f'got message for unknown message_type: {_type}, ignoring...') 347 | elif message.type == aiohttp.WSMsgType.CLOSE: 348 | msg_lookup = { 349 | 4000: "4000 - Internal server error", 350 | 4001: "4001 - Client sent inbound traffic", 351 | 4002: "4002 - Client failed ping-pong", 352 | 4003: "4003 - Connection unused, you have to create a subscription within 10 seconds", 353 | 4004: "4004 - Reconnect grace time expired", 354 | 4005: "4005 - Network timeout", 355 | 4006: "4006 - Network error", 356 | 4007: "4007 - Invalid reconnect" 357 | } 358 | self.logger.info(f'Websocket closing: {msg_lookup.get(message.data, f" {message.data} - Unknown")}') 359 | elif message.type == aiohttp.WSMsgType.CLOSING: 360 | if self._reconnect and self._reconnect.session.status == "connected": 361 | self._connection = self._reconnect.connection 362 | self.active_session = self._reconnect.session 363 | self._reconnect = None 364 | self.logger.debug("websocket session_reconnect completed") 365 | continue 366 | elif message.type == aiohttp.WSMsgType.CLOSED: 367 | self.logger.debug('websocket is closing') 368 | if self._running: 369 | if self._is_reconnecting: 370 | continue 371 | try: 372 | await self._connect(is_startup=False) 373 | except TwitchBackendException: 374 | self.logger.exception('Connection to EventSub websocket lost and unable to reestablish connection!') 375 | break 376 | else: 377 | break 378 | elif message.type == aiohttp.WSMsgType.ERROR: 379 | self.logger.warning('error in websocket: ' + str(self._connection.exception())) 380 | break 381 | except CancelledError: 382 | return 383 | 384 | async def _build_request_header(self): 385 | token = await self._twitch.get_refreshed_user_auth_token() 386 | if token is None: 387 | raise TwitchAuthorizationException('no Authorization set!') 388 | return { 389 | 'Client-ID': self._twitch.app_id, 390 | 'Content-Type': 'application/json', 391 | 'Authorization': f'Bearer {token}' 392 | } 393 | 394 | async def _unsubscribe_hook(self, topic_id: str) -> bool: 395 | self._active_subscriptions.pop(topic_id, None) 396 | return True 397 | 398 | async def _resubscribe(self): 399 | self.logger.debug('resubscribe to all active subscriptions of this websocket...') 400 | subs = self._active_subscriptions.copy() 401 | self._active_subscriptions = {} 402 | try: 403 | for sub in subs.values(): 404 | await self._subscribe(**sub) 405 | except BaseException: 406 | self.logger.exception('exception while resubscribing') 407 | if not self._active_subscriptions: # Restore old subscriptions for next reconnect 408 | self._active_subscriptions = subs 409 | return 410 | self.logger.debug('done resubscribing!') 411 | 412 | def _reset_timeout(self): 413 | self._reconnect_timeout = datetime.datetime.now() + datetime.timedelta(seconds=self.active_session.keepalive_timeout_seconds*2) 414 | 415 | async def _handle_revocation(self, data: dict): 416 | _payload = data.get('payload', {}) 417 | sub_id: str = _payload.get('subscription', {}).get('id') 418 | self.logger.debug(f'got revocation of subscription {sub_id} for reason {_payload.get("subscription").get("status")}') 419 | if sub_id not in self._active_subscriptions.keys(): 420 | self.logger.warning(f'unknown subscription {sub_id} got revoked. ignore') 421 | return 422 | self._active_subscriptions.pop(sub_id) 423 | self._callbacks.pop(sub_id) 424 | if self.revokation_handler is not None: 425 | t = self._callback_loop.create_task(self.revokation_handler(_payload)) 426 | t.add_done_callback(self._task_callback) 427 | 428 | async def _handle_reconnect(self, data: dict): 429 | session = data.get('payload', {}).get('session', {}) 430 | new_session = Session.from_twitch(session) 431 | self.logger.debug(f"got request from websocket to reconnect, reconnect url: {new_session.reconnect_url}") 432 | self._reset_timeout() 433 | new_connection = None 434 | retry = 0 435 | need_retry = True 436 | while need_retry and retry <= 5: # We only have up to 30 seconds to move to new connection 437 | need_retry = False 438 | try: 439 | new_connection = await self._session.ws_connect(new_session.reconnect_url) 440 | except Exception as err: 441 | self.logger.warning(f"reconnection attempt failed because {err}, retry in {self.reconnect_delay_steps[retry]} seconds...") 442 | await asyncio.sleep(self.reconnect_delay_steps[retry]) 443 | retry += 1 444 | need_retry = True 445 | if new_connection is None: # We failed to establish new connection, do nothing and force a full refresh 446 | self.logger.warning(f"Failed to establish connection to {new_session.reconnect_url}, Twitch will close and we'll reconnect") 447 | return 448 | reconnect = Reconnect(session=new_session, connection=new_connection) 449 | try: 450 | message: WSMessage = await reconnect.connection.receive(timeout=30) 451 | except asyncio.TimeoutError: 452 | await reconnect.connection.close() 453 | self.logger.warning(f"Reconnect socket got a timeout waiting for first message {reconnect.session}") 454 | return 455 | self._reset_timeout() 456 | if message.type != aiohttp.WSMsgType.TEXT: 457 | self.logger.warning(f"Reconnect socket got an unknown message {message}") 458 | await reconnect.connection.close() 459 | return 460 | data = message.json() 461 | message_type = data.get('metadata', {}).get('message_type') 462 | if message_type != "session_welcome": 463 | self.logger.warning(f"Reconnect socket got a non session_welcome first message {data}") 464 | await reconnect.connection.close() 465 | return 466 | session_dict = data.get('payload', {}).get('session', {}) 467 | reconnect.session = Session.from_twitch(session_dict) 468 | self._reconnect = reconnect 469 | await self._connection.close() # This will wake up _task_receive with a CLOSING message 470 | 471 | async def _handle_welcome(self, data: dict): 472 | session = data.get('payload', {}).get('session', {}) 473 | self.active_session = Session.from_twitch(session) 474 | self.logger.debug(f'new session id: {self.active_session.id}') 475 | self._reset_timeout() 476 | if self._is_reconnecting: 477 | await self._resubscribe() 478 | self._is_reconnecting = False 479 | self._startup_complete = True 480 | 481 | async def _handle_keepalive(self, data: dict): 482 | self.logger.debug('got session keep alive') 483 | self._reset_timeout() 484 | 485 | async def _handle_notification(self, data: dict): 486 | self._reset_timeout() 487 | _payload = data.get('payload', {}) 488 | _payload['metadata'] = data.get('metadata', {}) 489 | sub_id = _payload.get('subscription', {}).get('id') 490 | callback = self._callbacks.get(sub_id) 491 | if callback is None: 492 | self.logger.error(f'received event for unknown subscription with ID {sub_id}') 493 | else: 494 | msg_id = _payload['metadata'].get('message_id') 495 | if msg_id is not None and msg_id in self._msg_id_history: 496 | self.logger.warning(f'got message with duplicate id {msg_id}! Discarding message') 497 | else: 498 | t = self._callback_loop.create_task(callback['callback'](callback['event'](**_payload))) 499 | t.add_done_callback(self._task_callback) 500 | 501 | -------------------------------------------------------------------------------- /twitchAPI/object/api.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022. Lena "Teekeks" During 2 | """ 3 | Objects used by the Twitch API 4 | ------------------------------ 5 | """ 6 | 7 | from datetime import datetime 8 | from typing import Optional, List, Dict 9 | 10 | from twitchAPI.object.base import TwitchObject, IterTwitchObject, AsyncIterTwitchObject 11 | from twitchAPI.type import StatusCode, VideoType, HypeTrainContributionMethod, DropsEntitlementFulfillmentStatus, CustomRewardRedemptionStatus, \ 12 | PollStatus, PredictionStatus 13 | 14 | 15 | __all__ = ['TwitchUser', 'TwitchUserFollow', 'TwitchUserFollowResult', 'DateRange', 16 | 'ExtensionAnalytic', 'GameAnalytics', 'CreatorGoal', 'BitsLeaderboardEntry', 'BitsLeaderboard', 'ProductCost', 'ProductData', 17 | 'ExtensionTransaction', 'ChatSettings', 'CreatedClip', 'Clip', 'CodeStatus', 'Game', 'AutoModStatus', 'BannedUser', 'BanUserResponse', 18 | 'BlockedTerm', 'Moderator', 'CreateStreamMarkerResponse', 'Stream', 'StreamMarker', 'StreamMarkers', 'GetStreamMarkerResponse', 19 | 'BroadcasterSubscription', 'BroadcasterSubscriptions', 'UserSubscription', 'StreamTag', 'TeamUser', 'ChannelTeam', 'UserExtension', 20 | 'ActiveUserExtension', 'UserActiveExtensions', 'VideoMutedSegments', 'Video', 'ChannelInformation', 'SearchChannelResult', 21 | 'SearchCategoryResult', 'StartCommercialResult', 'Cheermote', 'GetCheermotesResponse', 'HypeTrainContribution', 'HypeTrainEventData', 22 | 'HypeTrainEvent', 'DropsEntitlement', 'MaxPerStreamSetting', 'MaxPerUserPerStreamSetting', 'GlobalCooldownSetting', 'CustomReward', 23 | 'PartialCustomReward', 'CustomRewardRedemption', 'ChannelEditor', 'BlockListEntry', 'PollChoice', 'Poll', 'Predictor', 'PredictionOutcome', 24 | 'Prediction', 'RaidStartResult', 'ChatBadgeVersion', 'ChatBadge', 'Emote', 'ChannelEmote', 'UserEmote', 'GetChannelEmotesResponse', 25 | 'GetEmotesResponse', 'EventSubSubscription', 'GetEventSubSubscriptionResult', 'StreamCategory', 'ChannelStreamScheduleSegment', 26 | 'StreamVacation', 'ChannelStreamSchedule', 'ChannelVIP', 'UserChatColor', 'Chatter', 'GetChattersResponse', 'ShieldModeStatus', 27 | 'CharityAmount', 'CharityCampaign', 'CharityCampaignDonation', 'AutoModSettings', 'ChannelFollower', 'ChannelFollowersResult', 28 | 'FollowedChannel', 'FollowedChannelsResult', 'ContentClassificationLabel', 'AdSchedule', 'AdSnoozeResponse', 'SendMessageResponse', 29 | 'ChannelModerator', 'UserEmotesResponse', 'WarnResponse', 'SharedChatParticipant', 'SharedChatSession'] 30 | 31 | 32 | class TwitchUser(TwitchObject): 33 | id: str 34 | login: str 35 | display_name: str 36 | type: str 37 | broadcaster_type: str 38 | description: str 39 | profile_image_url: str 40 | offline_image_url: str 41 | view_count: int 42 | email: str = None 43 | created_at: datetime 44 | 45 | 46 | class TwitchUserFollow(TwitchObject): 47 | from_id: str 48 | from_login: str 49 | from_name: str 50 | to_id: str 51 | to_login: str 52 | to_name: str 53 | followed_at: datetime 54 | 55 | 56 | class TwitchUserFollowResult(AsyncIterTwitchObject[TwitchUserFollow]): 57 | total: int 58 | data: List[TwitchUserFollow] 59 | 60 | 61 | class ChannelFollower(TwitchObject): 62 | followed_at: datetime 63 | user_id: str 64 | user_name: str 65 | user_login: str 66 | 67 | 68 | class ChannelFollowersResult(AsyncIterTwitchObject[ChannelFollower]): 69 | total: int 70 | data: List[ChannelFollower] 71 | 72 | 73 | class FollowedChannel(TwitchObject): 74 | broadcaster_id: str 75 | broadcaster_login: str 76 | broadcaster_name: str 77 | followed_at: datetime 78 | 79 | 80 | class FollowedChannelsResult(AsyncIterTwitchObject[FollowedChannel]): 81 | total: int 82 | data: List[FollowedChannel] 83 | 84 | 85 | class DateRange(TwitchObject): 86 | ended_at: datetime 87 | started_at: datetime 88 | 89 | 90 | class ExtensionAnalytic(TwitchObject): 91 | extension_id: str 92 | URL: str 93 | type: str 94 | date_range: DateRange 95 | 96 | 97 | class GameAnalytics(TwitchObject): 98 | game_id: str 99 | URL: str 100 | type: str 101 | date_range: DateRange 102 | 103 | 104 | class CreatorGoal(TwitchObject): 105 | id: str 106 | broadcaster_id: str 107 | broadcaster_name: str 108 | broadcaster_login: str 109 | type: str 110 | description: str 111 | current_amount: int 112 | target_amount: int 113 | created_at: datetime 114 | 115 | 116 | class BitsLeaderboardEntry(TwitchObject): 117 | user_id: str 118 | user_login: str 119 | user_name: str 120 | rank: int 121 | score: int 122 | 123 | 124 | class BitsLeaderboard(IterTwitchObject): 125 | data: List[BitsLeaderboardEntry] 126 | date_range: DateRange 127 | total: int 128 | 129 | 130 | class ProductCost(TwitchObject): 131 | amount: int 132 | type: str 133 | 134 | 135 | class ProductData(TwitchObject): 136 | domain: str 137 | sku: str 138 | cost: ProductCost 139 | 140 | 141 | class ExtensionTransaction(TwitchObject): 142 | id: str 143 | timestamp: datetime 144 | broadcaster_id: str 145 | broadcaster_login: str 146 | broadcaster_name: str 147 | user_id: str 148 | user_login: str 149 | user_name: str 150 | product_type: str 151 | product_data: ProductData 152 | inDevelopment: bool 153 | displayName: str 154 | expiration: str 155 | broadcast: str 156 | 157 | 158 | class ChatSettings(TwitchObject): 159 | broadcaster_id: str 160 | moderator_id: str 161 | emote_mode: bool 162 | slow_mode: bool 163 | slow_mode_wait_time: int 164 | follower_mode: bool 165 | follower_mode_duration: int 166 | subscriber_mode: bool 167 | unique_chat_mode: bool 168 | non_moderator_chat_delay: bool 169 | non_moderator_chat_delay_duration: int 170 | 171 | 172 | class CreatedClip(TwitchObject): 173 | id: str 174 | edit_url: str 175 | 176 | 177 | class Clip(TwitchObject): 178 | id: str 179 | url: str 180 | embed_url: str 181 | broadcaster_id: str 182 | broadcaster_name: str 183 | creator_id: str 184 | creator_name: str 185 | video_id: str 186 | game_id: str 187 | language: str 188 | title: str 189 | view_count: int 190 | created_at: datetime 191 | thumbnail_url: str 192 | duration: float 193 | vod_offset: int 194 | is_featured: bool 195 | 196 | 197 | class CodeStatus(TwitchObject): 198 | code: str 199 | status: StatusCode 200 | 201 | 202 | class Game(TwitchObject): 203 | box_art_url: str 204 | id: str 205 | name: str 206 | igdb_id: str 207 | 208 | 209 | class AutoModStatus(TwitchObject): 210 | msg_id: str 211 | is_permitted: bool 212 | 213 | 214 | class BannedUser(TwitchObject): 215 | user_id: str 216 | user_login: str 217 | user_name: str 218 | expires_at: datetime 219 | created_at: datetime 220 | reason: str 221 | moderator_id: str 222 | moderator_login: str 223 | moderator_name: str 224 | 225 | 226 | class BanUserResponse(TwitchObject): 227 | broadcaster_id: str 228 | moderator_id: str 229 | user_id: str 230 | created_at: datetime 231 | end_time: datetime 232 | 233 | 234 | class BlockedTerm(TwitchObject): 235 | broadcaster_id: str 236 | moderator_id: str 237 | id: str 238 | text: str 239 | created_at: datetime 240 | updated_at: datetime 241 | expires_at: datetime 242 | 243 | 244 | class Moderator(TwitchObject): 245 | user_id: str 246 | user_login: str 247 | user_name: str 248 | 249 | 250 | class CreateStreamMarkerResponse(TwitchObject): 251 | id: str 252 | created_at: datetime 253 | description: str 254 | position_seconds: int 255 | 256 | 257 | class Stream(TwitchObject): 258 | id: str 259 | user_id: str 260 | user_login: str 261 | user_name: str 262 | game_id: str 263 | game_name: str 264 | type: str 265 | title: str 266 | viewer_count: int 267 | started_at: datetime 268 | language: str 269 | thumbnail_url: str 270 | tag_ids: List[str] 271 | is_mature: bool 272 | tags: List[str] 273 | 274 | 275 | class StreamMarker(TwitchObject): 276 | id: str 277 | created_at: datetime 278 | description: str 279 | position_seconds: int 280 | URL: str 281 | 282 | 283 | class StreamMarkers(TwitchObject): 284 | video_id: str 285 | markers: List[StreamMarker] 286 | 287 | 288 | class GetStreamMarkerResponse(TwitchObject): 289 | user_id: str 290 | user_name: str 291 | user_login: str 292 | videos: List[StreamMarkers] 293 | 294 | 295 | class BroadcasterSubscription(TwitchObject): 296 | broadcaster_id: str 297 | broadcaster_login: str 298 | broadcaster_name: str 299 | gifter_id: str 300 | gifter_login: str 301 | gifter_name: str 302 | is_gift: bool 303 | tier: str 304 | plan_name: str 305 | user_id: str 306 | user_name: str 307 | user_login: str 308 | 309 | 310 | class BroadcasterSubscriptions(AsyncIterTwitchObject[BroadcasterSubscription]): 311 | total: int 312 | points: int 313 | data: List[BroadcasterSubscription] 314 | 315 | 316 | class UserSubscription(TwitchObject): 317 | broadcaster_id: str 318 | broadcaster_name: str 319 | broadcaster_login: str 320 | is_gift: bool 321 | tier: str 322 | 323 | 324 | class StreamTag(TwitchObject): 325 | tag_id: str 326 | is_auto: bool 327 | localization_names: Dict[str, str] 328 | localization_descriptions: Dict[str, str] 329 | 330 | 331 | class TeamUser(TwitchObject): 332 | user_id: str 333 | user_name: str 334 | user_login: str 335 | 336 | 337 | class ChannelTeam(TwitchObject): 338 | broadcaster_id: str 339 | broadcaster_name: str 340 | broadcaster_login: str 341 | background_image_url: str 342 | banner: str 343 | users: Optional[List[TeamUser]] 344 | created_at: datetime 345 | updated_at: datetime 346 | info: str 347 | thumbnail_url: str 348 | team_name: str 349 | team_display_name: str 350 | id: str 351 | 352 | 353 | class UserExtension(TwitchObject): 354 | id: str 355 | version: str 356 | can_activate: bool 357 | type: List[str] 358 | name: str 359 | 360 | 361 | class ActiveUserExtension(UserExtension): 362 | x: int 363 | y: int 364 | active: bool 365 | 366 | 367 | class UserActiveExtensions(TwitchObject): 368 | panel: Dict[str, ActiveUserExtension] 369 | overlay: Dict[str, ActiveUserExtension] 370 | component: Dict[str, ActiveUserExtension] 371 | 372 | 373 | class VideoMutedSegments(TwitchObject): 374 | duration: int 375 | offset: int 376 | 377 | 378 | class Video(TwitchObject): 379 | id: str 380 | stream_id: str 381 | user_id: str 382 | user_login: str 383 | user_name: str 384 | title: str 385 | description: str 386 | created_at: datetime 387 | published_at: datetime 388 | url: str 389 | thumbnail_url: str 390 | viewable: str 391 | view_count: int 392 | language: str 393 | type: VideoType 394 | duration: str 395 | muted_segments: List[VideoMutedSegments] 396 | 397 | 398 | class ChannelInformation(TwitchObject): 399 | broadcaster_id: str 400 | broadcaster_login: str 401 | broadcaster_name: str 402 | game_name: str 403 | game_id: str 404 | broadcaster_language: str 405 | title: str 406 | delay: int 407 | tags: List[str] 408 | content_classification_labels: List[str] 409 | is_branded_content: bool 410 | 411 | 412 | class SearchChannelResult(TwitchObject): 413 | broadcaster_language: str 414 | """The ISO 639-1 two-letter language code of the language used by the broadcaster. For example, en for English. 415 | If the broadcaster uses a language not in the list of supported stream languages, the value is other.""" 416 | broadcaster_login: str 417 | """The broadcaster’s login name.""" 418 | display_name: str 419 | """The broadcaster’s display name.""" 420 | game_id: str 421 | """The ID of the game that the broadcaster is playing or last played.""" 422 | game_name: str 423 | """The name of the game that the broadcaster is playing or last played.""" 424 | id: str 425 | """An ID that uniquely identifies the channel (this is the broadcaster’s ID).""" 426 | is_live: bool 427 | """A Boolean value that determines whether the broadcaster is streaming live. Is True if the broadcaster is streaming live; otherwise, False.""" 428 | tags: List[str] 429 | """The tags applied to the channel.""" 430 | thumbnail_url: str 431 | """A URL to a thumbnail of the broadcaster’s profile image.""" 432 | title: str 433 | """The stream’s title. Is an empty string if the broadcaster didn’t set it.""" 434 | started_at: Optional[datetime] 435 | """The datetime of when the broadcaster started streaming. None if the broadcaster is not streaming live.""" 436 | 437 | 438 | class SearchCategoryResult(TwitchObject): 439 | id: str 440 | name: str 441 | box_art_url: str 442 | 443 | 444 | class StartCommercialResult(TwitchObject): 445 | length: int 446 | message: str 447 | retry_after: int 448 | 449 | 450 | class Cheermote(TwitchObject): 451 | min_bits: int 452 | id: str 453 | color: str 454 | images: Dict[str, Dict[str, Dict[str, str]]] 455 | can_cheer: bool 456 | show_in_bits_card: bool 457 | 458 | 459 | class GetCheermotesResponse(TwitchObject): 460 | prefix: str 461 | tiers: List[Cheermote] 462 | type: str 463 | order: int 464 | last_updated: datetime 465 | is_charitable: bool 466 | 467 | 468 | class HypeTrainContribution(TwitchObject): 469 | total: int 470 | type: HypeTrainContributionMethod 471 | user: str 472 | 473 | 474 | class HypeTrainEventData(TwitchObject): 475 | broadcaster_id: str 476 | cooldown_end_time: datetime 477 | expires_at: datetime 478 | goal: int 479 | id: str 480 | last_contribution: HypeTrainContribution 481 | level: int 482 | started_at: datetime 483 | top_contributions: List[HypeTrainContribution] 484 | total: int 485 | 486 | 487 | class HypeTrainEvent(TwitchObject): 488 | id: str 489 | event_type: str 490 | event_timestamp: datetime 491 | version: str 492 | event_data: HypeTrainEventData 493 | 494 | 495 | class DropsEntitlement(TwitchObject): 496 | id: str 497 | benefit_id: str 498 | timestamp: datetime 499 | user_id: str 500 | game_id: str 501 | fulfillment_status: DropsEntitlementFulfillmentStatus 502 | updated_at: datetime 503 | 504 | 505 | class MaxPerStreamSetting(TwitchObject): 506 | is_enabled: bool 507 | max_per_stream: int 508 | 509 | 510 | class MaxPerUserPerStreamSetting(TwitchObject): 511 | is_enabled: bool 512 | max_per_user_per_stream: int 513 | 514 | 515 | class GlobalCooldownSetting(TwitchObject): 516 | is_enabled: bool 517 | global_cooldown_seconds: int 518 | 519 | 520 | class CustomReward(TwitchObject): 521 | broadcaster_name: str 522 | broadcaster_login: str 523 | broadcaster_id: str 524 | id: str 525 | image: Dict[str, str] 526 | background_color: str 527 | is_enabled: bool 528 | cost: int 529 | title: str 530 | prompt: str 531 | is_user_input_required: bool 532 | max_per_stream_setting: MaxPerStreamSetting 533 | max_per_user_per_stream_setting: MaxPerUserPerStreamSetting 534 | global_cooldown_setting: GlobalCooldownSetting 535 | is_paused: bool 536 | is_in_stock: bool 537 | default_image: Dict[str, str] 538 | should_redemptions_skip_request_queue: bool 539 | redemptions_redeemed_current_stream: int 540 | cooldown_expires_at: datetime 541 | 542 | 543 | class PartialCustomReward(TwitchObject): 544 | id: str 545 | title: str 546 | prompt: str 547 | cost: int 548 | 549 | 550 | class CustomRewardRedemption(TwitchObject): 551 | broadcaster_name: str 552 | broadcaster_login: str 553 | broadcaster_id: str 554 | id: str 555 | user_id: str 556 | user_name: str 557 | user_input: str 558 | status: CustomRewardRedemptionStatus 559 | redeemed_at: datetime 560 | reward: PartialCustomReward 561 | 562 | 563 | class ChannelEditor(TwitchObject): 564 | user_id: str 565 | user_name: str 566 | created_at: datetime 567 | 568 | 569 | class BlockListEntry(TwitchObject): 570 | user_id: str 571 | user_login: str 572 | user_name: str 573 | 574 | 575 | class PollChoice(TwitchObject): 576 | id: str 577 | title: str 578 | votes: int 579 | channel_point_votes: int 580 | 581 | 582 | class Poll(TwitchObject): 583 | id: str 584 | broadcaster_name: str 585 | broadcaster_id: str 586 | broadcaster_login: str 587 | title: str 588 | choices: List[PollChoice] 589 | channel_point_voting_enabled: bool 590 | channel_points_per_vote: int 591 | status: PollStatus 592 | duration: int 593 | started_at: datetime 594 | 595 | 596 | class Predictor(TwitchObject): 597 | user_id: str 598 | user_name: str 599 | user_login: str 600 | channel_points_used: int 601 | channel_points_won: int 602 | 603 | 604 | class PredictionOutcome(TwitchObject): 605 | id: str 606 | title: str 607 | users: int 608 | channel_points: int 609 | top_predictors: Optional[List[Predictor]] 610 | color: str 611 | 612 | 613 | class Prediction(TwitchObject): 614 | id: str 615 | broadcaster_id: str 616 | broadcaster_name: str 617 | broadcaster_login: str 618 | title: str 619 | winning_outcome_id: Optional[str] 620 | outcomes: List[PredictionOutcome] 621 | prediction_window: int 622 | status: PredictionStatus 623 | created_at: datetime 624 | ended_at: Optional[datetime] 625 | locked_at: Optional[datetime] 626 | 627 | 628 | class RaidStartResult(TwitchObject): 629 | created_at: datetime 630 | is_mature: bool 631 | 632 | 633 | class ChatBadgeVersion(TwitchObject): 634 | id: str 635 | image_url_1x: str 636 | image_url_2x: str 637 | image_url_4x: str 638 | title: str 639 | description: str 640 | click_action: Optional[str] 641 | click_url: Optional[str] 642 | 643 | 644 | class ChatBadge(TwitchObject): 645 | set_id: str 646 | versions: List[ChatBadgeVersion] 647 | 648 | 649 | class Emote(TwitchObject): 650 | id: str 651 | name: str 652 | images: Dict[str, str] 653 | emote_type: str 654 | emote_set_id: str 655 | format: List[str] 656 | scale: List[str] 657 | theme_mode: List[str] 658 | 659 | 660 | class ChannelEmote(Emote): 661 | tier: str 662 | 663 | 664 | class UserEmote(Emote): 665 | owner_id: str 666 | 667 | 668 | class GetChannelEmotesResponse(IterTwitchObject): 669 | data: List[ChannelEmote] 670 | template: str 671 | 672 | 673 | class GetEmotesResponse(IterTwitchObject): 674 | data: List[Emote] 675 | template: str 676 | 677 | 678 | class EventSubSubscription(TwitchObject): 679 | id: str 680 | status: str 681 | type: str 682 | version: str 683 | condition: Dict[str, str] 684 | created_at: datetime 685 | transport: Dict[str, str] 686 | cost: int 687 | 688 | 689 | class GetEventSubSubscriptionResult(AsyncIterTwitchObject[EventSubSubscription]): 690 | total: int 691 | total_cost: int 692 | max_total_cost: int 693 | data: List[EventSubSubscription] 694 | 695 | 696 | class StreamCategory(TwitchObject): 697 | id: str 698 | name: str 699 | 700 | 701 | class ChannelStreamScheduleSegment(TwitchObject): 702 | id: str 703 | start_time: datetime 704 | end_time: datetime 705 | title: str 706 | canceled_until: Optional[datetime] 707 | category: StreamCategory 708 | is_recurring: bool 709 | 710 | 711 | class StreamVacation(TwitchObject): 712 | start_time: datetime 713 | end_time: datetime 714 | 715 | 716 | class ChannelStreamSchedule(AsyncIterTwitchObject[ChannelStreamScheduleSegment]): 717 | segments: List[ChannelStreamScheduleSegment] 718 | broadcaster_id: str 719 | broadcaster_name: str 720 | broadcaster_login: str 721 | vacation: Optional[StreamVacation] 722 | 723 | 724 | class ChannelVIP(TwitchObject): 725 | user_id: str 726 | user_name: str 727 | user_login: str 728 | 729 | 730 | class UserChatColor(TwitchObject): 731 | user_id: str 732 | user_name: str 733 | user_login: str 734 | color: str 735 | 736 | 737 | class Chatter(TwitchObject): 738 | user_id: str 739 | user_login: str 740 | user_name: str 741 | 742 | 743 | class GetChattersResponse(AsyncIterTwitchObject[Chatter]): 744 | data: List[Chatter] 745 | total: int 746 | 747 | 748 | class ShieldModeStatus(TwitchObject): 749 | is_active: bool 750 | moderator_id: str 751 | moderator_login: str 752 | moderator_name: str 753 | last_activated_at: Optional[datetime] 754 | 755 | 756 | class CharityAmount(TwitchObject): 757 | value: int 758 | decimal_places: int 759 | currency: str 760 | 761 | 762 | class CharityCampaign(TwitchObject): 763 | id: str 764 | broadcaster_id: str 765 | broadcaster_login: str 766 | broadcaster_name: str 767 | charity_name: str 768 | charity_description: str 769 | charity_logo: str 770 | charity_website: str 771 | current_amount: CharityAmount 772 | target_amount: CharityAmount 773 | 774 | 775 | class CharityCampaignDonation(TwitchObject): 776 | id: str 777 | campaign_id: str 778 | user_id: str 779 | user_name: str 780 | user_login: str 781 | amount: CharityAmount 782 | 783 | 784 | class AutoModSettings(TwitchObject): 785 | broadcaster_id: str 786 | moderator_id: str 787 | overall_level: Optional[int] 788 | disability: int 789 | aggression: int 790 | sexuality_sex_or_gender: int 791 | misogyny: int 792 | bullying: int 793 | swearing: int 794 | race_ethnicity_or_religion: int 795 | sex_based_terms: int 796 | 797 | 798 | class ContentClassificationLabel(TwitchObject): 799 | id: str 800 | description: str 801 | name: str 802 | 803 | 804 | class AdSchedule(TwitchObject): 805 | snooze_count: int 806 | """The number of snoozes available for the broadcaster.""" 807 | snooze_refresh_at: Optional[datetime] 808 | """The UTC timestamp when the broadcaster will gain an additional snooze.""" 809 | next_ad_at: Optional[datetime] 810 | """The UTC timestamp of the broadcaster’s next scheduled ad. Empty if the channel has no ad scheduled or is not live.""" 811 | duration: int 812 | """The length in seconds of the scheduled upcoming ad break.""" 813 | last_ad_at: Optional[datetime] 814 | """The UTC timestamp of the broadcaster’s last ad-break. Empty if the channel has not run an ad or is not live.""" 815 | preroll_free_time: int 816 | """The amount of pre-roll free time remaining for the channel in seconds. Returns 0 if they are currently not pre-roll free.""" 817 | 818 | 819 | class AdSnoozeResponse(TwitchObject): 820 | snooze_count: int 821 | """The number of snoozes available for the broadcaster.""" 822 | snooze_refresh_at: Optional[datetime] 823 | """The UTC timestamp when the broadcaster will gain an additional snooze""" 824 | next_ad_at: Optional[datetime] 825 | """The UTC timestamp of the broadcaster’s next scheduled ad""" 826 | 827 | 828 | class SendMessageDropReason(TwitchObject): 829 | code: str 830 | """Code for why the message was dropped.""" 831 | message: str 832 | """Message for why the message was dropped.""" 833 | 834 | 835 | class SendMessageResponse(TwitchObject): 836 | message_id: str 837 | """The message id for the message that was sent.""" 838 | is_sent: bool 839 | """If the message passed all checks and was sent.""" 840 | drop_reason: Optional[SendMessageDropReason] 841 | """The reason the message was dropped, if any.""" 842 | 843 | 844 | class ChannelModerator(TwitchObject): 845 | broadcaster_id: str 846 | """An ID that uniquely identifies the channel this user can moderate.""" 847 | broadcaster_login: str 848 | """The channel’s login name.""" 849 | broadcaster_name: str 850 | """The channels’ display name.""" 851 | 852 | 853 | class UserEmotesResponse(AsyncIterTwitchObject): 854 | template: str 855 | """A templated URL. Uses the values from the id, format, scale, and theme_mode fields to replace the like-named placeholder strings in the 856 | templated URL to create a CDN (content delivery network) URL that you use to fetch the emote.""" 857 | data: List[UserEmote] 858 | 859 | 860 | class WarnResponse(TwitchObject): 861 | broadcaster_id: str 862 | """The ID of the channel in which the warning will take effect.""" 863 | user_id: str 864 | """The ID of the warned user.""" 865 | moderator_id: str 866 | """The ID of the user who applied the warning.""" 867 | reason: str 868 | """The reason provided for warning.""" 869 | 870 | 871 | class SharedChatParticipant(TwitchObject): 872 | broadcaster_id: str 873 | """The User ID of the participant channel.""" 874 | 875 | 876 | class SharedChatSession(TwitchObject): 877 | session_id: str 878 | """The unique identifier for the shared chat session.""" 879 | host_broadcaster_id: str 880 | """The User ID of the host channel.""" 881 | participants: List[SharedChatParticipant] 882 | """The list of participants in the session.""" 883 | created_at: datetime 884 | """The UTC timestamp when the session was created.""" 885 | updated_at: datetime 886 | """The UTC timestamp when the session was last updated.""" 887 | -------------------------------------------------------------------------------- /twitchAPI/oauth.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020. Lena "Teekeks" During 2 | """ 3 | User OAuth Authenticator and helper functions 4 | ============================================= 5 | 6 | User Authenticator 7 | ------------------ 8 | 9 | :const:`~twitchAPI.oauth.UserAuthenticator` is an alternative to various online services that give you a user auth token. 10 | It provides non-server and server options. 11 | 12 | Requirements for non-server environment 13 | *************************************** 14 | 15 | Since this tool opens a browser tab for the Twitch authentication, you can only use this tool on environments that can 16 | open a browser window and render the ``__ website. 17 | 18 | For my authenticator you have to add the following URL as a "OAuth Redirect URL": :code:`http://localhost:17563` 19 | You can set that `here in your twitch dev dashboard `__. 20 | 21 | Requirements for server environment 22 | *********************************** 23 | 24 | You need the user code provided by Twitch when the user logs-in at the url returned by :const:`~twitchAPI.oauth.UserAuthenticator.return_auth_url()`. 25 | 26 | Create the UserAuthenticator with the URL of your webserver that will handle the redirect, and add it as a "OAuth Redirect URL" 27 | You can set that `here in your twitch dev dashboard `__. 28 | 29 | .. seealso:: This tutorial has a more detailed example how to use UserAuthenticator on a headless server: :doc:`/tutorial/user-auth-headless` 30 | 31 | .. seealso:: You may also use the CodeFlow to generate your access token headless :const:`~twitchAPI.oauth.CodeFlow` 32 | 33 | Code example 34 | ************ 35 | 36 | .. code-block:: python 37 | 38 | from twitchAPI.twitch import Twitch 39 | from twitchAPI.oauth import UserAuthenticator 40 | from twitchAPI.type import AuthScope 41 | 42 | twitch = await Twitch('my_app_id', 'my_app_secret') 43 | 44 | target_scope = [AuthScope.BITS_READ] 45 | auth = UserAuthenticator(twitch, target_scope, force_verify=False) 46 | # this will open your default browser and prompt you with the twitch verification website 47 | token, refresh_token = await auth.authenticate() 48 | # add User authentication 49 | await twitch.set_user_authentication(token, target_scope, refresh_token) 50 | 51 | User Authentication Storage Helper 52 | ---------------------------------- 53 | 54 | :const:`~twitchAPI.oauth.UserAuthenticationStorageHelper` provides a simplified way to store & reuse user tokens. 55 | 56 | Code example 57 | ************ 58 | 59 | .. code-block:: python 60 | 61 | twitch = await Twitch(APP_ID, APP_SECRET) 62 | helper = UserAuthenticationStorageHelper(twitch, TARGET_SCOPES) 63 | await helper.bind()" 64 | 65 | .. seealso:: See :doc:`/tutorial/reuse-user-token` for more information. 66 | 67 | 68 | Class Documentation 69 | ------------------- 70 | """ 71 | import datetime 72 | import json 73 | import os.path 74 | from pathlib import PurePath 75 | 76 | import aiohttp 77 | 78 | from .twitch import Twitch 79 | from .helper import build_url, build_scope, get_uuid, TWITCH_AUTH_BASE_URL, fields_to_enum 80 | from .type import AuthScope, InvalidRefreshTokenException, UnauthorizedException, TwitchAPIException 81 | import webbrowser 82 | from aiohttp import web 83 | import asyncio 84 | from threading import Thread 85 | from concurrent.futures import CancelledError 86 | from logging import getLogger, Logger 87 | from typing import List, Union, Optional, Callable, Awaitable, Tuple 88 | 89 | __all__ = ['refresh_access_token', 'validate_token', 'get_user_info', 'revoke_token', 'CodeFlow', 'UserAuthenticator', 'UserAuthenticationStorageHelper'] 90 | 91 | 92 | async def refresh_access_token(refresh_token: str, 93 | app_id: str, 94 | app_secret: str, 95 | session: Optional[aiohttp.ClientSession] = None, 96 | auth_base_url: str = TWITCH_AUTH_BASE_URL): 97 | """Simple helper function for refreshing a user access token. 98 | 99 | :param str refresh_token: the current refresh_token 100 | :param str app_id: the id of your app 101 | :param str app_secret: the secret key of your app 102 | :param ~aiohttp.ClientSession session: optionally a active client session to be used for the web request to avoid having to open a new one 103 | :param auth_base_url: The URL to the Twitch API auth server |default| :const:`~twitchAPI.helper.TWITCH_AUTH_BASE_URL` 104 | :return: access_token, refresh_token 105 | :raises ~twitchAPI.type.InvalidRefreshTokenException: if refresh token is invalid 106 | :raises ~twitchAPI.type.UnauthorizedException: if both refresh and access token are invalid (eg if the user changes 107 | their password of the app gets disconnected) 108 | :rtype: (str, str) 109 | """ 110 | param = { 111 | 'refresh_token': refresh_token, 112 | 'client_id': app_id, 113 | 'grant_type': 'refresh_token', 114 | 'client_secret': app_secret 115 | } 116 | url = build_url(auth_base_url + 'token', {}) 117 | ses = session if session is not None else aiohttp.ClientSession() 118 | async with ses.post(url, data=param) as result: 119 | data = await result.json() 120 | if session is None: 121 | await ses.close() 122 | if data.get('status', 200) == 400: 123 | raise InvalidRefreshTokenException(data.get('message', '')) 124 | if data.get('status', 200) == 401: 125 | raise UnauthorizedException(data.get('message', '')) 126 | return data['access_token'], data['refresh_token'] 127 | 128 | 129 | async def validate_token(access_token: str, 130 | session: Optional[aiohttp.ClientSession] = None, 131 | auth_base_url: str = TWITCH_AUTH_BASE_URL) -> dict: 132 | """Helper function for validating a user or app access token. 133 | 134 | https://dev.twitch.tv/docs/authentication/validate-tokens 135 | 136 | :param access_token: either a user or app OAuth access token 137 | :param session: optionally a active client session to be used for the web request to avoid having to open a new one 138 | :param auth_base_url: The URL to the Twitch API auth server |default| :const:`~twitchAPI.helper.TWITCH_AUTH_BASE_URL` 139 | :return: response from the api 140 | """ 141 | header = {'Authorization': f'OAuth {access_token}'} 142 | url = build_url(auth_base_url + 'validate', {}) 143 | ses = session if session is not None else aiohttp.ClientSession() 144 | async with ses.get(url, headers=header) as result: 145 | data = await result.json() 146 | if session is None: 147 | await ses.close() 148 | return fields_to_enum(data, ['scopes'], AuthScope, None) 149 | 150 | 151 | async def get_user_info(access_token: str, 152 | session: Optional[aiohttp.ClientSession] = None, 153 | auth_base_url: str = TWITCH_AUTH_BASE_URL) -> dict: 154 | """Helper function to get claims information from an OAuth2 access token. 155 | 156 | https://dev.twitch.tv/docs/authentication/getting-tokens-oidc/#getting-claims-information-from-an-access-token 157 | 158 | :param access_token: a OAuth2 access token 159 | :param session: optionally a active client session to be used for the web request to avoid having to open a new one 160 | :param auth_base_url: The URL to the Twitch API auth server |default| :const:`~twitchAPI.helper.TWITCH_AUTH_BASE_URL` 161 | :return: response from the API 162 | """ 163 | header = {'Authorization': f'Bearer {access_token}', 164 | 'Content-Type': 'application/json'} 165 | url = build_url(auth_base_url + 'userinfo', {}) 166 | ses = session if session is not None else aiohttp.ClientSession() 167 | async with ses.get(url, headers=header) as result: 168 | data = await result.json() 169 | if session is None: 170 | await ses.close() 171 | return data 172 | 173 | 174 | async def revoke_token(client_id: str, 175 | access_token: str, 176 | session: Optional[aiohttp.ClientSession] = None, 177 | auth_base_url: str = TWITCH_AUTH_BASE_URL) -> bool: 178 | """Helper function for revoking a user or app OAuth access token. 179 | 180 | https://dev.twitch.tv/docs/authentication/revoke-tokens 181 | 182 | :param str client_id: client id belonging to the access token 183 | :param str access_token: user or app OAuth access token 184 | :param ~aiohttp.ClientSession session: optionally a active client session to be used for the web request to avoid having to open a new one 185 | :param auth_base_url: The URL to the Twitch API auth server |default| :const:`~twitchAPI.helper.TWITCH_AUTH_BASE_URL` 186 | :rtype: bool 187 | :return: :code:`True` if revoking succeeded, otherwise :code:`False` 188 | """ 189 | url = build_url(auth_base_url + 'revoke', { 190 | 'client_id': client_id, 191 | 'token': access_token 192 | }) 193 | ses = session if session is not None else aiohttp.ClientSession() 194 | async with ses.post(url) as result: 195 | ret = result.status == 200 196 | if session is None: 197 | await ses.close() 198 | return ret 199 | 200 | 201 | class CodeFlow: 202 | """Basic implementation of the CodeFlow User Authentication. 203 | 204 | Example use: 205 | 206 | .. code-block:: python 207 | 208 | APP_ID = "my_app_id" 209 | APP_SECRET = "my_app_secret" 210 | USER_SCOPES = [AuthScope.BITS_READ, AuthScope.BITS_WRITE] 211 | 212 | twitch = await Twitch(APP_ID, APP_SECRET) 213 | code_flow = CodeFlow(twitch, USER_SCOPES) 214 | code, url = await code_flow.get_code() 215 | print(url) # visit this url and complete the flow 216 | token, refresh = await code_flow.wait_for_auth_complete() 217 | await twitch.set_user_authentication(token, USER_SCOPES, refresh) 218 | """ 219 | def __init__(self, 220 | twitch: 'Twitch', 221 | scopes: List[AuthScope], 222 | auth_base_url: str = TWITCH_AUTH_BASE_URL): 223 | """ 224 | 225 | :param twitch: A twitch instance 226 | :param scopes: List of the desired Auth scopes 227 | :param auth_base_url: The URL to the Twitch API auth server |default| :const:`~twitchAPI.helper.TWITCH_AUTH_BASE_URL` 228 | """ 229 | self._twitch: 'Twitch' = twitch 230 | self._client_id: str = twitch.app_id 231 | self._scopes: List[AuthScope] = scopes 232 | self.logger: Logger = getLogger('twitchAPI.oauth.code_flow') 233 | """The logger used for OAuth related log messages""" 234 | self.auth_base_url: str = auth_base_url 235 | self._device_code: Optional[str] = None 236 | self._expires_in: Optional[datetime.datetime] = None 237 | 238 | async def get_code(self) -> Tuple[str, str]: 239 | """Requests a Code and URL from teh API to start the flow 240 | 241 | :return: The Code and URL used to further the flow 242 | """ 243 | async with aiohttp.ClientSession(timeout=self._twitch.session_timeout) as session: 244 | data = { 245 | 'client_id': self._client_id, 246 | 'scopes': build_scope(self._scopes) 247 | } 248 | async with session.post(self.auth_base_url + 'device', data=data) as result: 249 | data = await result.json() 250 | self._device_code = data['device_code'] 251 | self._expires_in = datetime.datetime.now() + datetime.timedelta(seconds=data['expires_in']) 252 | return data['user_code'], data['verification_uri'] 253 | 254 | async def wait_for_auth_complete(self) -> Tuple[str, str]: 255 | """Waits till the user completed the flow on teh website and then generates the tokens. 256 | 257 | :return: the generated access_token and refresh_token 258 | """ 259 | if self._device_code is None: 260 | raise ValueError('Please start the code flow first using CodeFlow.get_code()') 261 | request_data = { 262 | 'client_id': self._client_id, 263 | 'scopes': build_scope(self._scopes), 264 | 'device_code': self._device_code, 265 | 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code' 266 | } 267 | async with aiohttp.ClientSession(timeout=self._twitch.session_timeout) as session: 268 | while True: 269 | if datetime.datetime.now() > self._expires_in: 270 | raise TimeoutError('Timed out waiting for auth complete') 271 | async with session.post(self.auth_base_url + 'token', data=request_data) as result: 272 | result_data = await result.json() 273 | if result_data.get('access_token') is not None: 274 | # reset state for reuse before exit 275 | self._device_code = None 276 | self._expires_in = None 277 | return result_data['access_token'], result_data['refresh_token'] 278 | await asyncio.sleep(1) 279 | 280 | 281 | class UserAuthenticator: 282 | """Simple to use client for the Twitch User authentication flow. 283 | """ 284 | 285 | def __init__(self, 286 | twitch: 'Twitch', 287 | scopes: List[AuthScope], 288 | force_verify: bool = False, 289 | url: str = 'http://localhost:17563', 290 | host: str = '0.0.0.0', 291 | port: int = 17563, 292 | auth_base_url: str = TWITCH_AUTH_BASE_URL): 293 | """ 294 | 295 | :param twitch: A twitch instance 296 | :param scopes: List of the desired Auth scopes 297 | :param force_verify: If this is true, the user will always be prompted for authorization by twitch |default| :code:`False` 298 | :param url: The reachable URL that will be opened in the browser. |default| :code:`http://localhost:17563` 299 | :param host: The host the webserver will bind to. |default| :code:`0.0.0.0` 300 | :param port: The port that will be used for the webserver. |default| :code:`17653` 301 | :param auth_base_url: The URL to the Twitch API auth server |default| :const:`~twitchAPI.helper.TWITCH_AUTH_BASE_URL` 302 | """ 303 | self._twitch: 'Twitch' = twitch 304 | self._client_id: str = twitch.app_id 305 | self.scopes: List[AuthScope] = scopes 306 | self.force_verify: bool = force_verify 307 | self.logger: Logger = getLogger('twitchAPI.oauth') 308 | """The logger used for OAuth related log messages""" 309 | self.url = url 310 | self.auth_base_url: str = auth_base_url 311 | self.document: str = """ 312 | 313 | 314 | 315 | pyTwitchAPI OAuth 316 | 317 | 318 |

Thanks for Authenticating with pyTwitchAPI!

319 | You may now close this page. 320 | 321 | """ 322 | """The document that will be rendered at the end of the flow""" 323 | self.port: int = port 324 | """The port that will be used for the webserver. |default| :code:`17653`""" 325 | self.host: str = host 326 | """The host the webserver will bind to. |default| :code:`0.0.0.0`""" 327 | self.state: str = str(get_uuid()) 328 | """The state to be used for identification, |default| a random UUID""" 329 | self._callback_func = None 330 | self._server_running: bool = False 331 | self._loop: Union[asyncio.AbstractEventLoop, None] = None 332 | self._runner: Union[web.AppRunner, None] = None 333 | self._thread: Union[Thread, None] = None 334 | self._user_token: Union[str, None] = None 335 | self._can_close: bool = False 336 | self._is_closed = False 337 | 338 | def _build_auth_url(self): 339 | params = { 340 | 'client_id': self._twitch.app_id, 341 | 'redirect_uri': self.url, 342 | 'response_type': 'code', 343 | 'scope': build_scope(self.scopes), 344 | 'force_verify': str(self.force_verify).lower(), 345 | 'state': self.state 346 | } 347 | return build_url(self.auth_base_url + 'authorize', params) 348 | 349 | def _build_runner(self): 350 | app = web.Application() 351 | app.add_routes([web.get('/', self._handle_callback)]) 352 | return web.AppRunner(app) 353 | 354 | async def _run_check(self): 355 | while not self._can_close: 356 | await asyncio.sleep(0.1) 357 | await self._runner.shutdown() 358 | await self._runner.cleanup() 359 | self.logger.info('shutting down oauth Webserver') 360 | self._is_closed = True 361 | 362 | def _run(self, runner: web.AppRunner): 363 | self._runner = runner 364 | self._loop = asyncio.new_event_loop() 365 | asyncio.set_event_loop(self._loop) 366 | self._loop.run_until_complete(runner.setup()) 367 | site = web.TCPSite(runner, self.host, self.port) 368 | self._loop.run_until_complete(site.start()) 369 | self._server_running = True 370 | self.logger.info('running oauth Webserver') 371 | try: 372 | self._loop.run_until_complete(self._run_check()) 373 | except (CancelledError, asyncio.CancelledError): 374 | pass 375 | 376 | def _start(self): 377 | self._thread = Thread(target=self._run, args=(self._build_runner(),)) 378 | self._thread.start() 379 | 380 | def stop(self): 381 | """Manually stop the flow 382 | 383 | :rtype: None 384 | """ 385 | self._can_close = True 386 | 387 | async def _handle_callback(self, request: web.Request): 388 | val = request.rel_url.query.get('state') 389 | self.logger.debug(f'got callback with state {val}') 390 | # invalid state! 391 | if val != self.state: 392 | return web.Response(status=401) 393 | self._user_token = request.rel_url.query.get('code') 394 | if self._user_token is None: 395 | # must provide code 396 | return web.Response(status=400) 397 | if self._callback_func is not None: 398 | self._callback_func(self._user_token) 399 | return web.Response(text=self.document, content_type='text/html') 400 | 401 | def return_auth_url(self): 402 | """Returns the URL that will authenticate the app, used for headless server environments.""" 403 | return self._build_auth_url() 404 | 405 | async def mock_authenticate(self, user_id: str) -> str: 406 | """Authenticate with a mocked auth flow via ``twitch-cli`` 407 | 408 | For more info see :doc:`/tutorial/mocking` 409 | 410 | :param user_id: the id of the user to generate a auth token for 411 | :return: the user auth token 412 | """ 413 | param = { 414 | 'client_id': self._client_id, 415 | 'client_secret': self._twitch.app_secret, 416 | 'code': self._user_token, 417 | 'user_id': user_id, 418 | 'scope': build_scope(self.scopes), 419 | 'grant_type': 'user_token' 420 | } 421 | url = build_url(self.auth_base_url + 'authorize', param) 422 | async with aiohttp.ClientSession(timeout=self._twitch.session_timeout) as session: 423 | async with session.post(url) as response: 424 | data: dict = await response.json() 425 | if data is None or data.get('access_token') is None: 426 | raise TwitchAPIException(f'Authentication failed:\n{str(data)}') 427 | return data['access_token'] 428 | 429 | async def authenticate(self, 430 | callback_func: Optional[Callable[[str, str], None]] = None, 431 | user_token: Optional[str] = None, 432 | browser_name: Optional[str] = None, 433 | browser_new: int = 2, 434 | use_browser: bool = True, 435 | auth_url_callback: Optional[Callable[[str], Awaitable[None]]] = None): 436 | """Start the user authentication flow\n 437 | If callback_func is not set, authenticate will wait till the authentication process finished and then return 438 | the access_token and the refresh_token 439 | If user_token is set, it will be used instead of launching the webserver and opening the browser 440 | 441 | :param callback_func: Function to call once the authentication finished. 442 | :param user_token: Code obtained from twitch to request the access and refresh token. 443 | :param browser_name: The browser that should be used, None means that the system default is used. 444 | See `the webbrowser documentation `__ for more info 445 | |default|:code:`None` 446 | :param browser_new: controls in which way the link will be opened in the browser. 447 | See `the webbrowser documentation `__ for more info 448 | |default|:code:`2` 449 | :param use_browser: controls if a browser should be opened. 450 | If set to :const:`False`, the browser will not be opened and the URL to be opened will either be printed to the info log or 451 | send to the specified callback function (controlled by :const:`~twitchAPI.oauth.UserAuthenticator.authenticate.params.auth_url_callback`) 452 | |default|:code:`True` 453 | :param auth_url_callback: a async callback that will be called with the url to be used for the authentication flow should 454 | :const:`~twitchAPI.oauth.UserAuthenticator.authenticate.params.use_browser` be :const:`False`. 455 | If left as None, the URL will instead be printed to the info log 456 | |default|:code:`None` 457 | :return: None if callback_func is set, otherwise access_token and refresh_token 458 | :raises ~twitchAPI.type.TwitchAPIException: if authentication fails 459 | :rtype: None or (str, str) 460 | """ 461 | self._callback_func = callback_func 462 | self._can_close = False 463 | self._user_token = None 464 | self._is_closed = False 465 | 466 | if user_token is None: 467 | self._start() 468 | # wait for the server to start up 469 | while not self._server_running: 470 | await asyncio.sleep(0.01) 471 | if use_browser: 472 | # open in browser 473 | browser = webbrowser.get(browser_name) 474 | browser.open(self._build_auth_url(), new=browser_new) 475 | else: 476 | if auth_url_callback is not None: 477 | await auth_url_callback(self._build_auth_url()) 478 | else: 479 | self.logger.info(f"To authenticate open: {self._build_auth_url()}") 480 | while self._user_token is None: 481 | await asyncio.sleep(0.01) 482 | # now we need to actually get the correct token 483 | else: 484 | self._user_token = user_token 485 | self._is_closed = True 486 | 487 | param = { 488 | 'client_id': self._client_id, 489 | 'client_secret': self._twitch.app_secret, 490 | 'code': self._user_token, 491 | 'grant_type': 'authorization_code', 492 | 'redirect_uri': self.url 493 | } 494 | url = build_url(self.auth_base_url + 'token', param) 495 | async with aiohttp.ClientSession(timeout=self._twitch.session_timeout) as session: 496 | async with session.post(url) as response: 497 | data: dict = await response.json() 498 | if callback_func is None: 499 | self.stop() 500 | while not self._is_closed: 501 | await asyncio.sleep(0.1) 502 | if data.get('access_token') is None: 503 | raise TwitchAPIException(f'Authentication failed:\n{str(data)}') 504 | return data['access_token'], data['refresh_token'] 505 | elif user_token is not None and self._callback_func is not None: 506 | self._callback_func(data['access_token'], data['refresh_token']) 507 | 508 | 509 | class UserAuthenticationStorageHelper: 510 | """Helper for automating the generation and storage of a user auth token.\n 511 | See :doc:`/tutorial/reuse-user-token` for more detailed examples and use cases. 512 | 513 | Basic example use: 514 | 515 | .. code-block:: python 516 | 517 | twitch = await Twitch(APP_ID, APP_SECRET) 518 | helper = UserAuthenticationStorageHelper(twitch, TARGET_SCOPES) 519 | await helper.bind()""" 520 | 521 | def __init__(self, 522 | twitch: 'Twitch', 523 | scopes: List[AuthScope], 524 | storage_path: Optional[PurePath] = None, 525 | auth_generator_func: Optional[Callable[['Twitch', List[AuthScope]], Awaitable[Tuple[str, str]]]] = None, 526 | auth_base_url: str = TWITCH_AUTH_BASE_URL): 527 | self.twitch = twitch 528 | self.logger: Logger = getLogger('twitchAPI.oauth.storage_helper') 529 | """The logger used for OAuth Storage Helper related log messages""" 530 | self._target_scopes = scopes 531 | self.storage_path = storage_path if storage_path is not None else PurePath('user_token.json') 532 | self.auth_generator = auth_generator_func if auth_generator_func is not None else self._default_auth_gen 533 | self.auth_base_url: str = auth_base_url 534 | 535 | async def _default_auth_gen(self, twitch: 'Twitch', scopes: List[AuthScope]) -> Tuple[str, str]: 536 | auth = UserAuthenticator(twitch, scopes, force_verify=True, auth_base_url=self.auth_base_url) 537 | return await auth.authenticate() 538 | 539 | async def _update_stored_tokens(self, token: str, refresh_token: str): 540 | self.logger.info('user token got refreshed and stored') 541 | with open(self.storage_path, 'w') as _f: 542 | json.dump({'token': token, 'refresh': refresh_token}, _f) 543 | 544 | async def bind(self): 545 | """Bind the helper to the provided instance of twitch and sets the user authentication.""" 546 | self.twitch.user_auth_refresh_callback = self._update_stored_tokens 547 | needs_auth = True 548 | if os.path.exists(self.storage_path): 549 | try: 550 | with open(self.storage_path, 'r') as _f: 551 | creds = json.load(_f) 552 | await self.twitch.set_user_authentication(creds['token'], self._target_scopes, creds['refresh']) 553 | except: 554 | self.logger.info('stored token invalid, refreshing...') 555 | else: 556 | needs_auth = False 557 | if needs_auth: 558 | token, refresh_token = await self.auth_generator(self.twitch, self._target_scopes) 559 | with open(self.storage_path, 'w') as _f: 560 | json.dump({'token': token, 'refresh': refresh_token}, _f) 561 | await self.twitch.set_user_authentication(token, self._target_scopes, refresh_token) 562 | --------------------------------------------------------------------------------