pax_global_header00006660000000000000000000000064146373306620014524gustar00rootroot0000000000000052 comment=0d97664affc1cd60fc3ec349654ccd2201f9355c Teekeks-pyTwitchAPI-0d97664/000077500000000000000000000000001463733066200155475ustar00rootroot00000000000000Teekeks-pyTwitchAPI-0d97664/.gitignore000066400000000000000000000002631463733066200175400ustar00rootroot00000000000000/venv/ /venv3.9/ /.idea/ __pycache__/ test_data.py example.py MANIFEST /dist/ /twitchAPI.egg-info/ /docs/_build/ /chat_example.py requirements_dev.txt /tests/ pytest.ini Makefile Teekeks-pyTwitchAPI-0d97664/.readthedocs.yaml000066400000000000000000000002431463733066200207750ustar00rootroot00000000000000version: 2 sphinx: configuration: docs/conf.py build: os: ubuntu-22.04 tools: python: "3.8" python: install: - requirements: docs/requirements.txt Teekeks-pyTwitchAPI-0d97664/LICENSE.txt000066400000000000000000000020651463733066200173750ustar00rootroot00000000000000MIT License Copyright (c) 2020 Lena "Teekeks" During Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.Teekeks-pyTwitchAPI-0d97664/MANIFEST.in000066400000000000000000000001501463733066200173010ustar00rootroot00000000000000exclude .readthedocs.yaml exclude .gitignore exclude requirements.txt prune venv prune docs prune tests Teekeks-pyTwitchAPI-0d97664/README.md000066400000000000000000000224701463733066200170330ustar00rootroot00000000000000# Python Twitch API [![PyPI verion](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) This is a full implementation of the Twitch Helix API, PubSub, EventSub and Chat in python 3.7+. ## Installation Install using pip: ```pip install twitchAPI``` ## Documentation and Support A full API documentation can be found [on readthedocs.org](https://pytwitchapi.readthedocs.io/en/stable/index.html). For support please join the [Twitch API discord server](https://discord.gg/tu2Dmc7gpd) ## Usage ### Basic API calls Setting up an Instance of the Twitch API and get your User ID: ```python from twitchAPI.twitch import Twitch from twitchAPI.helper import first import asyncio async def twitch_example(): # initialize the twitch instance, this will by default also create a app authentication for you twitch = await Twitch('app_id', 'app_secret') # call the API for the data of your twitch user # this returns a async generator that can be used to iterate over all results # but we are just interested in the first result # using the first helper makes this easy. user = await first(twitch.get_users(logins='your_twitch_user')) # print the ID of your user or do whatever else you want with it print(user.id) # run this example asyncio.run(twitch_example()) ``` ### Authentication The Twitch API knows 2 different authentications. App and User Authentication. Which one you need (or if one at all) depends on what calls you want to use. 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. **Please read the docs for more details and examples on how to set and use Authentication!** #### App Authentication App authentication is super simple, just do the following: ```python from twitchAPI.twitch import Twitch twitch = await Twitch('my_app_id', 'my_app_secret') ``` ### User Authentication 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. For my Authenticator you have to add the following URL as a "OAuth Redirect URL": ```http://localhost:17563``` You can set that [here in your twitch dev dashboard](https://dev.twitch.tv/console). ```python from twitchAPI.twitch import Twitch from twitchAPI.oauth import UserAuthenticator from twitchAPI.type import AuthScope twitch = await Twitch('my_app_id', 'my_app_secret') target_scope = [AuthScope.BITS_READ] auth = UserAuthenticator(twitch, target_scope, force_verify=False) # this will open your default browser and prompt you with the twitch verification website token, refresh_token = await auth.authenticate() # add User authentication await twitch.set_user_authentication(token, target_scope, refresh_token) ``` You can reuse this token and use the refresh_token to renew it: ```python from twitchAPI.oauth import refresh_access_token new_token, new_refresh_token = await refresh_access_token('refresh_token', 'client_id', 'client_secret') ``` ### AuthToken refresh callback Optionally you can set a callback for both user access token refresh and app access token refresh. ```python from twitchAPI.twitch import Twitch async def user_refresh(token: str, refresh_token: str): print(f'my new user token is: {token}') async def app_refresh(token: str): print(f'my new app token is: {token}') twitch = await Twitch('my_app_id', 'my_app_secret') twitch.app_auth_refresh_callback = app_refresh twitch.user_auth_refresh_callback = user_refresh ``` ## EventSub EventSub lets you listen for events that happen on Twitch. The EventSub client runs in its own thread, calling the given callback function whenever an event happens. There are multiple EventSub transports available, used for different use cases. 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) ## PubSub PubSub enables you to subscribe to a topic, for updates (e.g., when a user cheers in a channel). A more detailed documentation can be found [here on readthedocs](https://pytwitchapi.readthedocs.io/en/stable/modules/twitchAPI.pubsub.html) ```python from twitchAPI.pubsub import PubSub from twitchAPI.twitch import Twitch from twitchAPI.helper import first from twitchAPI.type import AuthScope from twitchAPI.oauth import UserAuthenticator import asyncio from pprint import pprint from uuid import UUID APP_ID = 'my_app_id' APP_SECRET = 'my_app_secret' USER_SCOPE = [AuthScope.WHISPERS_READ] TARGET_CHANNEL = 'teekeks42' async def callback_whisper(uuid: UUID, data: dict) -> None: print('got callback for UUID ' + str(uuid)) pprint(data) async def run_example(): # setting up Authentication and getting your user id twitch = await Twitch(APP_ID, APP_SECRET) auth = UserAuthenticator(twitch, [AuthScope.WHISPERS_READ], force_verify=False) token, refresh_token = await auth.authenticate() # you can get your user auth token and user auth refresh token following the example in twitchAPI.oauth await twitch.set_user_authentication(token, [AuthScope.WHISPERS_READ], refresh_token) user = await first(twitch.get_users(logins=[TARGET_CHANNEL])) # starting up PubSub pubsub = PubSub(twitch) pubsub.start() # you can either start listening before or after you started pubsub. uuid = await pubsub.listen_whispers(user.id, callback_whisper) input('press ENTER to close...') # you do not need to unlisten to topics before stopping but you can listen and unlisten at any moment you want await pubsub.unlisten(uuid) pubsub.stop() await twitch.close() asyncio.run(run_example()) ``` ## Chat A simple twitch chat bot. Chat bots can join channels, listen to chat and reply to messages, commands, subscriptions and many more. A more detailed documentation can be found [here on readthedocs](https://pytwitchapi.readthedocs.io/en/stable/modules/twitchAPI.chat.html) ### Example code for a simple bot ```python from twitchAPI.twitch import Twitch from twitchAPI.oauth import UserAuthenticator from twitchAPI.type import AuthScope, ChatEvent from twitchAPI.chat import Chat, EventData, ChatMessage, ChatSub, ChatCommand import asyncio APP_ID = 'my_app_id' APP_SECRET = 'my_app_secret' USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] TARGET_CHANNEL = 'teekeks42' # this will be called when the event READY is triggered, which will be on bot start async def on_ready(ready_event: EventData): print('Bot is ready for work, joining channels') # join our target channel, if you want to join multiple, either call join for each individually # or even better pass a list of channels as the argument await ready_event.chat.join_room(TARGET_CHANNEL) # you can do other bot initialization things in here # this will be called whenever a message in a channel was send by either the bot OR another user async def on_message(msg: ChatMessage): print(f'in {msg.room.name}, {msg.user.name} said: {msg.text}') # this will be called whenever someone subscribes to a channel async def on_sub(sub: ChatSub): print(f'New subscription in {sub.room.name}:\\n' f' Type: {sub.sub_plan}\\n' f' Message: {sub.sub_message}') # this will be called whenever the !reply command is issued async def test_command(cmd: ChatCommand): if len(cmd.parameter) == 0: await cmd.reply('you did not tell me what to reply with') else: await cmd.reply(f'{cmd.user.name}: {cmd.parameter}') # this is where we set up the bot async def run(): # set up twitch api instance and add user authentication with some scopes twitch = await Twitch(APP_ID, APP_SECRET) auth = UserAuthenticator(twitch, USER_SCOPE) token, refresh_token = await auth.authenticate() await twitch.set_user_authentication(token, USER_SCOPE, refresh_token) # create chat instance chat = await Chat(twitch) # register the handlers for the events you want # listen to when the bot is done starting up and ready to join channels chat.register_event(ChatEvent.READY, on_ready) # listen to chat messages chat.register_event(ChatEvent.MESSAGE, on_message) # listen to channel subscriptions chat.register_event(ChatEvent.SUB, on_sub) # there are more events, you can view them all in this documentation # you can directly register commands and their handlers, this will register the !reply command chat.register_command('reply', test_command) # we are done with our setup, lets start this bot up! chat.start() # lets run till we press enter in the console try: input('press ENTER to stop\n') finally: # now we can close the chat bot and the twitch api client chat.stop() await twitch.close() # lets run our setup asyncio.run(run()) ``` Teekeks-pyTwitchAPI-0d97664/docs/000077500000000000000000000000001463733066200164775ustar00rootroot00000000000000Teekeks-pyTwitchAPI-0d97664/docs/Makefile000066400000000000000000000011721463733066200201400ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) Teekeks-pyTwitchAPI-0d97664/docs/_static/000077500000000000000000000000001463733066200201255ustar00rootroot00000000000000Teekeks-pyTwitchAPI-0d97664/docs/_static/css/000077500000000000000000000000001463733066200207155ustar00rootroot00000000000000Teekeks-pyTwitchAPI-0d97664/docs/_static/css/custom.css000066400000000000000000000001101463733066200227310ustar00rootroot00000000000000.default-value-section .default-value-label { font-style: italic; } Teekeks-pyTwitchAPI-0d97664/docs/_static/logo.png000066400000000000000000011470331463733066200216040ustar00rootroot00000000000000PNG  IHDR@ɫzTXtRaw profile type exifxڭu%Ê5Zyz/P=b9$2##"2?{𕫏&Rs[lKx_g:ys(3<t___7z}và'{~YAnsܽ6?i:kEG`7~?'C_kk-~vp<7zVq~ߍ9dyDf?}9 ʯIMƉp{Yrv;by"~\tuwܾ? 1 ?DYj(I]vZXIX}nxXqw~6/7:G0wX1./|1 eNr qt~RbL7̕ v;[>nMS}ŕ!ىlv!l8G+܇p$J(z=k H!BjZ$+3VP7OL)TRM-r)\xPbI%RjiPcM5Rkm74L˭Z띇عW΁GqGu'qguٗ_aAfUV]m6PqwuX;ēN>N+_=sΚ{eD5v ':Iё hbʜrf&eRrS`Χs7SRȜQ>e׼/݆ U!1!巈J{9ήg\˥:s9d+Fk? $l !שH7.&:?su;!Ba˵Y{Z?͟N Ƕ\.}':JnŜYn]@bP]gYtt>F^D"|':q㒎8b3tF[ˁOdl~Li g3VkgVJ`X?Tϩq*r&=}˧%7#-Cbtiw>ZV) 'i0rKHiޟѯ8c܂3\%bގ=i%SJJproGGdhFBXhim9퓸,B;6ut\G5" +!1;b1bW`Hzo\ܫnH∽9֐2bΙDEm9'j6U o+}P6WqԁQwكʋ9 z^8[۹Epwv3OMS A缥INh\X49 E'zBL[DGY{Cί~S>)ގ? <2tӞ:d3|#QaB݈qbHt]y wm3.}=5ico_G-g fS_BY1cp4;B"7*}0(¤@A^S;?'1EP2v;(;~&NZf~0f~v.t0ϓR_S{ׄAfHcN*gl!_n6/ʣf1yi?=֎dļC̐.=~~B\17J:[Rvh5ޒRO*#aXj4\ΘEܑiTƕ Sض1*•n lUhupAGg3 SNѺR  cqeD4kXXSӛ _JN?7Tpr0 Lt`mwbch2zQ0*5BOzΩF-:r]EWQqHuv8\.|?Ʉ9 aT(IJ[o6rzBXuCK 89:3?P_F[=C`(Vp-#}I b8`%0sqX wY)]o(XE,^h3a&6H53KGR[t%.G^&DGe TB0e AV1§@Wf> =-vR WU%4?-6Ņa(J*]R/5Zh *ܓ^1 #(?yN~_#),^eV& qY7 Ua߬E@KB8Qap+5O˃eR 񉋄. W8 ᘧuyZ\|t1$0nM!'8qTN*-ʼn0;:]uOpRڳGT.ޝ~bgM>_,e 2g0_P_՗}! RAKs ^|5'ΩJ3ERDoM}ll<?K9ϐ> 7O /aBy_(Bl st6hWiXzL7 Ma }5˴^ "_@yz. {~T_&FR 29k0m*'BcLP7A7u C/JM 0)P<7"Nv]b 3\V.lYyBIl> rĔ,˫#1IJi-A> "-od}1Gkh R2??qfZ ފޕIL&rt GR_B & .llD-} tK-Jt@ENaQ拖'T"E,Mz:ȟ@âeU iV-upn4s<¹v30NZt$ لV Z39V7(39=c&$|%µdP_lq%4A&U.9%YNFy-z,?Ӫ}"OIEX;AA/9I]ݪEnDF-p+ (d*nky&ځ([WO4R[i^+4A&g [ * )SЌ ;dh픾r_lZ.;֖w_Vޣ 7L&rÊK8 MXӠ_;UT#h7t& &1_d"c=fMriUX`$_I {a ɭkaF`  lY45-oE ?HE%`[Nȼk"3>P3 ڈ)@|eg@B^b|\hqY6HLK"4l#U ,jJog'"OѷH(Q]cȂ?qLhIЛ^]Z8y@CIk4+jNc,V.h'`Z6X|5Z|piW%KlBl孲Þ2Fſn8<]\?KVjĿ-Z [ՐaӬ 1$ 9Z*| MĀ]cV@_`<ܷi Gꒉh\[.}fe0aZU2wO.\cВ(5o&He=hњצy*sbpG¢RGSJİb"|Ŧ?E}mSznFj7YH|ҠߜH|k PUh uV\8.HzC;(U!R++@.ܫ"K>Ivݩ^~c[+ adLV,.=lM$B ٗpMp;" Zx[Y]ͦv]|Bh.QzC$АO5 g=h_vdj71G*)#<^v""FӶ}Pb lN+v @q:i-{@7CƇ/ڟ_>{KBrpKsؤZ`1 gix#h2iK|o{%j X%vjU;v0Pr9:$5VI܁gj .FB[R W+e 쌷m*Hjwk(^$ыOFXGA$GT%j;o1?Vy4!_4h30gclHTf,tqq9p7Ϲ)JZסI~ B|txm~E=54 IulZѢ٬rM}vlѴ)G1HU[j{_I83`.L /m~+еiE)ܨaZnTբ@ul [(GrwZ8p4w3K vLSʧ\}c\Zcs^P5ﮡHm\C ԭx,^"IT4v{ ^P.ќTogM'! YDVh3i<(uʔ vY}-Pז</1pXarI/=k8 :/,(7hu1^N"2|z{YFc4փhW3~e1¯4Vue&{r_ם~Yy?.ߠY& ;p#g_ZXJyzE#PزIihu2*JjDӮg\C/ANO-%XV<2>eKS ?5li; @-˭,`OۻtE 5j-:U鰶m֞ݳS/}9<{ʽ `I.Jyz-1bSBKڍʯx90aIOI}##zHJD/wC^զH/Ok.92ZDHh䰈ݏ -\b8~Z5vv,+w<\87IQhWZBfl(X( ''<7ͥSg[OkdrvDf7Mjm^4Mj g!7o~W!M'nv$c&7LAiCCPICC profilex}=H@_Sv␡:Y+(U,Vh/hbHR\ׂUg]\AIEJ_Rhq?{ܽffjI&|aE b ".1SOer_.Ƴ9HIot0 \\w4ytɐOS(3W8}r pp){}ݽ{rS4bKGDܨW< pHYs  ~tIME 7# IDATxaZ6FՏJUgNgiǨH5Fl]ckNR)%^ Wy~hD/p~A?|a=e/yO ༧ pӄ~X)~A?,aҰ_}Y/|I/=<m v ~~A?law_q_y/4m 16 87~x~A?ad/)s; a^C!WR Ta?a?a?a?a?a?a?a?a?a?a?C/8~2a?,~ a?E&|a?;DM, фkq|a߿N?pO~`SO!P u52_?-gz qn7OI)ק ˶5ay1,۽k0/yMK)m0n.97Ox_lMtmof^[tMǨlyoi=? e٣ώti=?9,xȴ;hn"=' WEX GT闛^u,vaD]׉YZlbR@2 `-WR~ "8 `\k,PVA 8-O}1pۯMJg'07 `,9X˻)`?p~/ o o o U,xN=gU2=ܧq$4q҉K\? `<`C/~/ ?y.ec_~ ߁n:yK?&$9𝟜3 QF׶9/'\ߡA~`|E./7 _|JSIZscO 8p.~"@:}.2q.;W? ~^)H`PY@?=<~gnv& p/?&\ppdr@+&Lu<`}Z~6a?|S iW__S,*Пپ\9`gi _9$@]|ZUo*Խ<1`~R s&.pn /_VS/6"oa+!Ukm_nz/$1ʺ wrdi{1?`v2(}-X M '&m7_߱_? [s`*_OevK&Kpqe1s\4i;.zf}J3.?}Oۏ'w8wj:LB_]/A/!!ݪW-*2? 7e/|ɛV1??d:?It']09r{[Op|k۫ ~xAw0=ܿj|ZP}c7ңuoh?v.L09ఫ4=aф&tۥ4&2>WυmVS?|O~x!o/oe@}`ޡU-eq˒zzoL~iEtvtk~(@.[ &px-e&l?`$ ˪gUkߡ_ߋ^]CVo5D^o<_诘Pm$_Tu/'k>5ғp6y\5?A ?39`zR MX29}>q&X詬 /,? '͙̿wn]&`-O?`v>N:M|' !t|~OX7 `4u1Cr>}#>Wh/KxYx5mU9S.ПO9~^~^?3G 4{J_sD4O4I831ܤÄ2wӍ.OH)M~-pM|(O__܍9zd:俥Moo꟩_<=-˶]>V{~ehZ: HtOOOHS䀅f'?er"',pn{f7W_I<{k5'UaXOL(?_?S_ׅ™O+OO dIz:;9`81`nR1 `"Np5Ҫuǟm7"~[߲5rᲭן.yOn}}^{@/- Lm7nn-3^m/Y~oy5֮_/+Q{y4\_O-5_o5P~._ϭ_gbi%KCk;#fkI[k]z}tec"d\Kn?[55V/ ksUKjk+%;izkk_ZviL 9\s}s_j%"GD.R"JSz2EqӺn8z99>#m|bh:>RD%(QWee\x)J]F^=~NQEDJw9_׭O-ݿꯟs8~ǏNQU*Q)Jw:}*o*G׬_3 `~Ku&-cnrskCUg YKvL%5G;U@ i? >t /Np} /npDt؃Wv:A?r|HRL,c n:k{_ٚwW=J:ǤDq*K6L/2+㰮DăRJH]߆Im>Y@Ouo/Q"w)_mU>VN 쿶- j.υYSٿdܲ*[4{._;A?a?l֠B ڷT̯V[nk{4DU.c\j]_F{.s2I`p8Iǀ=4xo7~vt^qeP|?#"R^{D_<0/yu3V0Cq'agӮ؍ N'J8縼D6|`t5U[K /m4~OM XυM8o 1GkCk _z[٪豺+܏;.u>NN3p_ ѝMwX?h?^7p|)6j OpDp|9b8a]q8!t]_0>Ka\&ߪJ{x~~wwl֗k֭0ly ԏ#bQ="FmO/cR0DlG!I:>TGWFnu1h|}#Mc2xy3ןL\q3vx1<@ ӆ{!X, ^u>ib1Rwyr?aA*'Ibw#ԲEV'Dn"AcJ7 EvDd=XQAm;ޟH$_:!`iS?_[?a?\閠Cg ckӡM~TˏozVQGqM8nr' wl  i8:9 Fx: KvW8/;s?f/ RHyyG!:sczGġ+ɤiE 'Ǫ ˪C6ʯF]@>YWjpN8-5࿴.sKS˖L"W߇Vk-k_Ҋ\"Miw sHm0 kM߆"}ny{v9ִ_0^ 1_l fuYTc@=?&q҅.+e<5 c7A nr@*J ʿ'  jpx5_> T?[VxՐ-me$T~~ɶn]Uy9A%0 w}S!oF}?$>}oe x[{?nY7޵qӕ\{WIH]0S K>PE*ڿPB:nu*IUuP &[/ [Y_ K%~EUkU<3k[+&0[~K]1/ޗA{rl['mK5ia|C*c%I!dY *W8w`P}aGD;DREtж[ _S>8 8ܷ= @: ?@ @ұ{At4Hix_=ܿt}n5n=Bg6Aks{kxm%>Oɶ9"b*a5~ﯧt( V71 08|YzŊ-ݕ-p6ٓ.wy>ps'ʹ!~D]}HQEʃ>Jn4?`|j$c59`}c<%A Npi})E]B[k3O_l-P IDATC{ͲkZ/WWȯ}[e> K6ІJط?'! ?} swYi@? 30Xn,^xVz!7{rq gsBϫSnTE*ݣ+D*]e|7R9^8N8'P?u}TÐ~X?0I3 :C7:qeB_oi*[;~L׆|ܺk?5[%}l%Eǰ9؏hJnǖ߰B]m9p?mx`s gv% >D=r'ڤm= HrS[vj$c:#R*z N࿮uc迫R{j+UUܞ o 0/[BsWIRj%^~M/rh_ږ%/a}M thWQ?۬]EA2򄚴ʢUF{=t .6ߕ.۶U)uA:L8UϪSj nĂ.&\ ׄBqǁ_ `䚠j5- ׆ke[_WW^}W^>~ٱboߵ/vSПP}~}Du0. * >_7+OQ.%vQ.]X%TE ?R,;TqnY%=@ۡ?t&׫;A˗UO;یCI׶D=-Zo_Sſb~jٹVkBus9e`]7䀾~GO[fSDSmR6MQA)G8p?[_Akͣ6^D4ѵ/)jRm߷|6}إAWJv@յ:TUUn/(`I4_2h-Sc*k>@+k[?"&ܿ_`I߶/m|ܵ?i߷cU}~Mܹ}~0?5/D?mI0IQEUIH ST)>0.) 8VׇC7@ם߾ip:oxJ5tMԥU ^1䟻~孭o o or)ܷگNZb_nT V;UpNlS6 |6oZQ"R7{~,"uG_M'.|^إou[>̹PUׇkk?^6=M0f\Mkgy~~omϐMHn s΋6F+[wr P}TjW\K:lD__Z_T\YÖuw?c~ck绌_[=ZG-RU*-G{j/[rڿ: ګZ:!`nV ?eV!Ҷsׯ_vYe)m_Jx./]UtCa%3XqABbCAwA݈HQJ*G*]؟KUGkַSTiRDURꮧR7!  L XzsS)& Uw[?# Ͳ[B-ˮYއ]~nG&bgwQ.ǖTmH}>AAg^7AÂC>{eY@SѤ&ؕ)bW"v)*R:Ck_!]/몽O]*.%E]ͷ&_YkU&ǹGnsAҖB_JM5=u)цDSV \RsxJ? 8L 8"tu} Eu>s?7 h뇸䄭s^ll{|_gldG*RԩDuQm_hUwإa @]}Kl, ۞1]߻GWsZ~~GKBKCkC5_6Mn WܶߗݶTOIU~)}`}==~V3+۾<2JRDf?HR:|kGTa2@TW}%TmAvXr5U[&WG~~g ׶O-[O-5&? UmNQ6#˺QGT͠2\$s;llo[?3MG}2^ ocهH*E[OU(OWU\b_hr_9bK4MDSx:E]RLÐ}Iu?\6u{**k#<*?W?}K5\uȿ6ПOߢ?%["Ҷϩ.i.7]ߤ6_YpM 'ֵ\GG)%*Gb_hhmK|oM{VxK쪶]rT)EUu) 'N ][lmt~GWEmm6oJ6iowcu-Ҷ?7Q/cp8/gn[ꓵ೷ݾqApQ/ A׍xO>H()GؗU)ުxJԃ]JQa@{]ݾRCq0U?;dmkUe$__O'[[e-o mbU7C~ߖ?~х]+DnY4h^/[!lew-[q{?]֣>}S""(}(%Qc"TN.wUV:.VEKQv)vsƧXg:~]_SͿd0nA~Γ3\ކ9>GG|lQx/Uk߇ K/Kw\9/+)\vr!;? cG-ESJhzUi5RU u?UmO]NKQ2_ٿ&/vsG.U-="j[@v]w%G#(u{T_3xM{mwIaQS\-cV?9,k}_:"Rv9iirDٕ(%-huQU)Rͭjɿקӥ]LAWKv8zD.)r)QJ9hJss:˶mv}nM{mwFAczymؗ\߾9hJ}9%)VGJUJQwUՆBqEK'sK[N6|+VoUbp ܞ)ҶoJ-o5]ߔ}ؗ݅⣤I}Sac #ˆ\ڠ[dck5nM)KD)JWU|T9soUWÿ:%:Ů(|&E*u0S6 -C9%9O#>rޟ^62?Gjwz9D,w,^x'>Go]"m[yȗƯ7|٧[r*VHhR])7إVxK%ުį: ?į"uR?VOKmwk[% a?#kߪzi?^7mΥ)>r=W_}hHm{Z<~z)%Ч@/'ckV;i-GJ*"R"GإRߪ?uwM.ϮDDU)LCKDg[WUuۤ=W?짮os!mn^A[W/i?EjD9{N^ۀooo#hqҚ;6GVп~z },/'ck7dBZiM:?7K|ܝ4SsQ4Qs:LOޞsT?x>sw{sm\ޫMt2_R|*>򰒿?x*QK7l~adC_Nփ&R)rʑS9r4\r{nܯ[]NsU]^VuS\5!z{Ϲ̵:~~g ѦlI_U}JDS)Ҷwm?J*>"E:_ry0ry0 fWAcKпck%">JT""( ?r&ǟį*W]⭎xnoűD%/ko ק֟&a?/liUA\5msΟ֭O߾)pmU>o}b_"&࿉l#Ej6nː}LOl߃N#m~c26;z9R+S\-xk"ު?uk׮=0ԟ #+esUBSק_z~k~7 ng nmkϭ/9hRsGN=-GVGמ~|ƹ?^dͺTor?ael[ӚDTJTRDݔxKmoWwbK]Rr)B7J~jY?`|}*muW oirY)8V7)޻??eeцQMo-w<\9[wߟ02[oN'OUQ"D.Ku*%2_Ї~~|ܲo+?RF)ϖA\P?"{oῴEҰ9rn2"h*>*K[7vmx/uGWU\#1r~7?ael[_>.TQrY9GRb'G5%vuĮS.QWUu]sn'vZ|=܇!e m{iؿE5lr[SKWSK#6/u[Tп-Fc0<޴tò僼jY-czZo㾤[*%#x/UiJSGJvou&bWx)vuOT_D,;R?\.6gsׯ ݞ ۀ?@?x #W]#EUHѣC rq~W #elUnz9HQ"JIO\R%~U%~U%KӔgWWbӀ?"&C\?Uٿ\h>n?|.rGӶK[UKMh&RB?z! #o|ւ~cغ^'}MGR":"vįS?r})m(HNa~+/濴W&llcc+^jlRؗtWTJ}%G.mR?Rp{G//wsis[@KS*DKU9ȥD_# ?rG=W]U.])u)uP՟?XhXrWa0"7[V"r)"aєS49׮[Q9*R%/$qh?~)[) xZ =*_lrhSsAQ[xBCП0/ytrnJny="FM6~<7/־ST;EO{S⟦?cKQuN*#WUG|B^T6:Q~!Բ{/ ?r&&\>/u:>JEML8| rf7={2~lˍn)WK7ӔUw_]ĿE4om(%H'W #p_~^Ҫ-ɲvː+6)~7uλ6/u:K#Et8GpۇGg .|cynHad-cG1c))oJK쪈_M_USwi߷4џׄ ?<a?OgS__ ۿM׾#GmRioﲋ?E c>o&4yVull[s9RDx/)(Q+K4%GDUjK].QU">s8*>&>+*E*R)OS&]є[g/ˆ}?Uo]x9kZmw)%A %a4e.Ѥh"Ms:XEWmwMoMR栾f_-zU>7{U"b)U?RmJKi>rys8i?nu7=a?O^U_>\vmпWJ웈&oSߦ?ZJo"E%sak02Ɩc#y5k}N7"vĿ9GS"reIo)LUWGdUpy?K7g%+D;k IDATS˦SeM&ߜOӶso߲?Q> ބՍ㛆@WtߘƖcblur}O)QR`aD_=ʿ/?<_ .|KKu /Whr&7)sj?jίcs5U~cz}I;GRվD6ϥD.u;vupǭoG~+jQտds[svs웈&#WޤxoRu[џ6/muc|+vxqMfzvZ̄ 12z?3/ڗUiou\"ꈪ:⏘?S)|{Z7 EC&GsT7W;\{TU|Drk_90YqT_\Y6'oȗGC-c.Gw?MDhJo._4%DEDhsϿ/!wEvC~ |A0s-s֠)Tg_sO9^~t֠. _yۃE/4;ol}n/m{J[J9#+KH)J9.} 5oi*2g"੭p.?o_j?&&力yu)ݵtF>h}UU[ƖV&R4%⽤ؗU/9KD/*vԢ7UoMx~TUO_OU_ iJ49bߤ)4Unv;ׇ?Q,F>h\~"4-cz[ڶ9v)"7[VrUzb?VÐ~h*uM[_-ܓs$T?U.䟫oM{S&\ş?]E\߲R>R4L.[u_DžW!\bl[_|*֓">xQ"E)9Jhr.\.W_"s k%~G x%~jm*sUsε쟻 97*~;W'_^MT3}eݯ{F E__T%Tir|4)6)]7Oh+sAǠ~b(,ߡ0؊ g?S~ckb9RhR}xsM}m_WmK3#nǁ-mkb_I˸fTP\sM㽉xno~w'(U#6 #}a!Vbl[۟0>#V/)S{)/D*U%vD"bw {о_vM?;ou*j2$!}Բq?\viBTCU4%MG='[CпQAcApM%+)ުQv?r.[Q_0 ǿϦ& 7Rп4;Sl߿dVS!8? VS]vTޝ'w%4*Qɖ`Y.? e_54fl=sAcDľDr:?"Rv]7G]]s9xߒ\8O)] ǿB5=}ppݹ!}fXѿ߹RtxF.oe'EadyUnl VS|#EU%G]}.QJD[_pTk%ǭo_Ӳ_;"_/ҿ~aUI%M&:W])u)mп&3mq #O5c+-c'T"rrTQJ)6%>>*ɖK[nI;x4a?Omd5m UU7M[Oy;şT+ #64[-czi"E)MD7)Ҿ:U%ުUj뭾a[qqS˦sߚK71Gp7h?U?R?\us)>*ޛt続Ot {<0Ril[O[9"ri4]_)ުn]fDq¿_6 7W?ϵR5VAeME;dW},G14xՒ âXp$~xZiK2(+W AcKB_^g)ݕ+gsQd䖱%bb됑1"V-Lt/ϤP*Kޔ\7X~{fcޒH|xF {d-3|gyRkwyGLK]o,7_*7n\?DK UN:vs:2O,,w[Ɩ犭USRA.SuRtI`IEDž5K[w"w9t @S2Lвt,?PsƠ/_,s/xPBo#Wa{!["+kǖ"SLTK&:"$uwQ߮ foӗ~ݕckY? 6gϬ}=XXȟS%1_*7?/ ="Swُ0Yf#!p볹u~$]ܲ7ח.?)#H*L2K7, L!HJ 9YC=/?x-셾RQ~տ͚4sU(ѿKl[;^D?&#)d :Er}Wi](]Ue潥{./ gwikR}w)6ȬiC~賱 \1IU.t1+}yWeW2oU(a]}j}>w.?lW-'[~bMc%]J*L&)+yҧ0u7⿑u[[ewT'+➬-#?%UuA_/5~,ܳYBoe:mO[Nl=&z#7 )\Ur%If"$%RaWyЕbrUOASQ?J}y%.ݿ>9}Il=p[~bΣqߔ%=L*UW$[vV~W7˿/ oWH-{aˬ!.Mb^+}y?hwn+5gMIRLwzeH:שh-7&7r-f7O9$><d?l#~)?ꏦs2}B) ʃnf_ݺȡ@޿e #[~bk>'RLYz> 2>U^/5N[7ׂ]\ ek>?`o&so\$]źR_ (\oc#7]ŒmM@2nԟ7j5ʥKTߟyq\l;"rrm2=Dl}lҗM=_?%̒pSe7b[sJ)Ev? `5R@??&K.QD%~/tVPa_%ZF. я'foEl]:'2\M\eNT3ӿ߭dwȿ5ZOd?<={dOc|]셾YoˌahUφGF[{b OlتKHR2%Ew%]!OY> ׉6]Lk_"YcReSJJވ3ytDv;xDx6D?2cgtR$2\$K*{WwigW)O d?<5{deT%MbTxPR}Sm݅7WNl="߉-bk$r}\2H|S}[ƿM]⿽llKd?ڬ?$kJRBԗ((<=2>,#}&$Dˎ4bKdM}z^%zڐloZ1{ﮣڑ~x0|iX߽1<&]1.BK'B_*T$mxv0yn[yD-=*Dl Ŗ[qquq\S#ͯB !_e329ט]q7mJ#@257мC{W,^[?_TIJ}gMZk!#:> 9El[lzڀM$3SYzL\eʐTut;]'e7]g?_"@]-?Vm.U2c-kV ]'=K&Zߊֵyڐ~bzB[7)<-TWYUTKnd)?t8?g 2yw%w$UQ:GB-.CRo\ya f|QW&;㏊-ݱ'=-Βӟ.RiSpRfz-JKe?׾\*#@n,S `*F'M/jo\Jf?0Y0ٗiE##_0~GŖ=Ba:B[&I7%Iry2eQHB*BY^G)-ofXf}רs<d?,W9eww?}1Z+R*7ED_ѿjqd$ 'ѿrW_[IRJJ$WQIYx-%3eJϒ+G9}r5/'~xJZv5?%%wUID霂RЗV7c=x۷GeezcbbKi>y%ӇDge\,eI!R}5Vҿ[گu,~Y@";ߚj}7sJ=KѥK#c3=tUIlBl!vzЯ=_%}%ӿh[I weC0YO'͆r)oҿ+9= P֖{t+?)RMy~Pd/<=2r/w[H߻[N/Nl!#ⶱS BBpA*ˋ_SοYw&, +?4X9{f9K/UգȮv&\Rێu` LHD.~?&=ԟ'8,|W%v-[MYMg7_SR"EN#%{#ulJԓ6g}ҿnyvOnΩ_/O 7ԑ,D #;ӓ 7&툭mOǦp꒾ܔrWtYGp)tŔ e^kv}w `6R",)7X~ )_Mds((iBv0ƑZ-4򳷦z9!hVH!#:L@l[[toK2Nf뫒.uIpo2ۂ+ו]|ϻעd3bJϙwi)])gWnx ]Vg#L& Y[{ZKx֦5-IߥidZ=Zݒcvo[Nl;<+J/g!,_Vֿߕm?44?, `P¿oRC,JJ׌|[Ϛ4sI##|?aE~RBң %J**,PRauM jeϬ.MW, ,[rs(SeAQHTPBU<$yCFQ[_6ꃰel9{lJRW4v]RL*edžrjgezu'* iPޫwWJRX$]R-!V~׷5iޒr'ET*dTTaoheۭ|5Ljewڈϥς?yPJqBPp9}!ցcIlVA(]:oe*n(TWY$ICٲ\a@lYheY1 T-[o8k%9'[\_ JܯtRIIeҒNY'K*iղ٭ԾwI%ױn; UPiם j稠hV'd#cӱ>VF[n98.%]Rt9IL:ҩp&"L7׃ϐݏ-A$sKImY¿2Rl2tIAg3RwYf.y}BaҔ7k*_**?[T%u#跬Y{C73GMKJ*]K:bĿ)y6oG'}Z2fGOl ѿVtv)$˿&(\NQsfL`]))nHw'~~&eKTv~~$A*$\BN-~l3mP~ҥ]'wEFJu*TPg}0bk#t_׏ bkB<.sl%7]$)ުҤ?\TEWiO)}m?:ZvCyt ,=6 IDATMfβ^^|vz%Wmv)LLBRQ>.o5skI!srM>?tC/ÿܕ$ORRRLR;SLC@S. <܁@9(E FF>Glmrgg:~AOl=,|S<&rISL/u*I!cL׽zd?ʒc[}~d1{q9J^RgVv?x%%*}ETZ, Ucks׆'#1@?$E7)JfsΕTCXd_¿yw ${^na52d?7XvJ}ˮba]-Gl%#C]?JT?V?;S>C"(g[~l~o55? 9!OO)+wqW\!)K}߿u>BF:pW:b]b -!rˏh\R:U"4M}b>v]0&އJ#`Op8s:M~>Oٟ^!gW{Ey}''\r\sH+UxRQg7OTt+Ug~ofڏ$!:Fpaʢ"I2oT뤤 EnU'|BF{l=`W') z'-tI9TB,aZoEv;{JX9%z u%kU(ak,wov7:YL%?跨U I!dIB*-\&kcm- CC5C EsuRԧW:{/[K=D5:Cwm3\qHWnz:$p|Yl=o2/Vs)\EH*??皱[Z`,t(!!$azȔ$Un(?^akYtY`ds&%OiuV%BR~HYoI>?}ݼ_և2yR8Ť3UtNA_^_:5KXr62'׭bq$=,h|cu:[&* W*'*9d7%G^ `?(-c;R._)G̝qJE}(?]>UO#CU엊`YLaDwI{= cmuM'Yp*L*SR2WLRBY%UdmGޢ["_2ׇ.nP\> tJwOee eiJ~&)%#E1NEe~|JB"Y7"nV>mXƿyHg]`f ,wnH)ϗI$S򠔂LTYa'c^oӈ>`q;B(~}?׾گ6ӮYR%\*%Ń.*TY*RhY{ozOl4}jrER&H\RDJ*~?l_#t{ p~؅%7XVuPVt)Z)m:]tRԩ.2?B)N}s4^6'`$Y%TxRJOeV^RPPm[6Sbk#xv|0sGHƧz6~>0?\.O`ҟd:G9JH|ΐ~ϕc(κ~~en&K2s32XU Wџ;lznQ~E}zKZ>CR\ H9iV=e͍Fw3i߆"I.YTa2V*U:;.E ;e[#;}ߺiWG'wbk{HnJ3sf~ಘ$3YGOoTin5[Ø?%P>J.Un\JP Nʚ{{>Yxt:FQ':e f#)㿤-/CҿهfZJIK’JK*%R%&tyyE;E?>6S"b+I:tN9.uvG%2sYrYc?2: vgiXC YTO˞qۚń:..N!pA*}R߰&(O og7d,eIA1Uٿ ܉e޿ [{xm]A?U3zغL$}EsB_o(˿Z`œ~~8}MJY)=~t42rѮX[~Zҟ:O]THu6PQd_Z+ Ӻ"+}hי>=)yTI)sUJX}2nB>Aڐ-DU Wt\!(¿X;=ﲦ#w3$ CYCս.T\?z]?kґ _}5 ߿dT(TgSZ?R-s(~7 sEߢo+eY~ߤ$+J+([rRnQa{Ca&mAF8ғŖoޜ}"-Lg_*))(\eUw~% (?poUwE7U)(zK~RD2v\5?g_#D}XG??WKSmPfC}C q+ H!OB\{Ca&u #~ɿulr/[|rd]h*_T$zEX o}p=!({CSY}J?rS9Gve٢L~I]C399ߒBLLU*tQʃLVzZv-_1B-D7TF)TX,5?U#ޚRz d?*KKOu h>OmJW=D l}$fMZmE|G;?T QU-_n泾N'}˥B\I^buW,%݄@l=BGH:0`DZ/i0}=DzƲۏFuZi]IQUJdr:TYX\u %v?BJ.U2SKXwtWniR}{s23r?Kѕ-o孎ԬI7w\c-8EYSHC+;~4Ͳmב`H7:?^=BI!r)WX}o[S=JhWPuĩ|2ѿelbN4.S%R,'H:?%ϥòCs@ݩ5ɳL R5mfM{]z0iIwJu>GNu*GoKZ]ظvmKyYu[wB+ߍLII]Lg繵wʋ@l5v?= S2 DƖ%[$?IhRufph̡kJ#@n7]J7Eӏ2IosF?,âNTȿW7{,yߞ~M!ߔog_KӂٗoxTh %`v=Ol=._KWuIV?(+o~KۿєW1dϦ+䒯M[._ϙ[(=cju6t4*wZS{9ewE;_ҵS@W77UJJUy҇'}xԇG咒£yckc\{"-b(,/ID]EtJsل+.%1Gϝghs\A9?zd ood1]EGJ.rfY]ߝ6W!riľW7m{L1<_ҥ2%}X҇x%s"W ՖUoH(lcE[~ebo :Ws)+Xf{8ubG `1Mܾ}M}޷RDy;g/8uR-?vgIsFjOtoK!|onvB/L*tJQ^)y}<??WIYXοO-wNoXv~IA!%<)$wW4AekAzM,:[neI[ 8[wƖT\_T#JU՝e׋C[jڜk@&s*?~mK'%ܜ~7N'J:)C>7Way|>I?$Dzl>ɿKn7g[<*>P½nm]:bqtga&#FmDu[VCTI:{P\et}F钤\iFv_fkI@"ȟ2cOeK3'mƔ]H_**~U*- I-Kn od=_r3~lRsn_R?6^"0dI'e[+Dlgf5d?7Jj2¤2I誢FK+Dz VNd?<^isco7muϥ5)jkw.?7 Eu'_W?U!`H^3ܷ[oRK{ڜCr9t IGQwH3$ b]ѯB[o-w)JT$)JB++a?w y?s@3stL~x D?9??YTRAIZ\h}su1~3M]sE( J*Y*ͮv1خbKd?ro"52ź%4霤*I)5?]a2Tc–CXI9to&ϙM*,Ĕ<],-^K|fe/BOho,sH==PJIL ,+S\[yseˍwbzغVoJ&ɥK*wZ>ө{d :R;Ss1,7~2u #e ϙE&ifK$Z}}DŽP5%2LnÓ[eVBW%#!"F괭Rï[;Jbb[ϗd:KYGwy<<,}?TgεUexOp8SYSK-*PrDLmiy\UqT9ooO#XvرL*uRRSeM!\-pIF[ֳVnwEwUISz={XV3'[ɣrF~[=Ae1 c+/]L])ݤ{;$co2-]\P˃)be$Uqdk{;l0-oPt ZI4Y,ygE}¾Lw~S=BW/Ҥ"lv~Iwg_>?G&2TY{Z_y߾̾:A*,Wj(-d*d G׏ "n++;M;v!-bUc+) K䪢,\Ut!}:Ld?܎{t :?K[V{-_dûq794WP^ϽP𷗙y?q?-gXj}>{=Y(OURtI`5ߕ3ͬIj?EC’ &e3ʧJ}6L:笣%7柪D}>fз?c}!gR%=m<2ѿ}l!R}=XTZ'WLRrWJCD]R~ؔn\I_zeu 60IA`Ga>{%甧oO.cgN[tLTDžis;< U94s[/jr_S{TNc rِǓmYwtJ*I\)7'ka?$i:7i+ Ww4\¿w;=t/s:-݊5e!'鴑Kڜm127-(td6-bcKO[͵`rS,KȱRSsSY` d?<57:FLnj(w7ʌW1*G/ ]ϺZf>J}ߥ/-ߝg:,}-pv/d}m\w7߿F;El[zre=uVJ^_/_rF6nt^K_9˔@uA~׈g ! GdOZ2!$)d9%7hY8ߗU{A]mq -[!b+JuVM_3s(==W_ : isk)nolFd|g-o~ll:hN2yG]"~Wl5?K&ཥQwQWFznz=kS_iDiDhu_+Kֿf߷ڷ#whm ȕ'-bk ]RDٟaH>ܹd?/o.͌eXn V3ڳySW:=產_Y6T4O*\;H[w3X»[ֺcXO[̥MR~f_55qMˡ vG|NjS}1_[潵~(a Q-z%-q] <ؚ:^ȁqpBv5|~_QoXWƖ,C6ќW?Ȭx{ٙp[lxB~U- vSJntϕg=7\"`p{og7Ӯ &>oqn\?6^۾}n|W9jǻDs Il[5C:x*1_dAzhw=o~ycp/˲'>VX۟el&#}3Vz:Te[ #QwSrV>v˹쉫]آDLI[_t{̶fcoݗ LV}12!2>v=38=bu=HFn.dG[{ǖﳑYV6%MS}S8c%S/o^L<OfWym$M-\vlC>m w0s|l֣a EF"CBnle+N!0d?ʣdp6NMF%ӵbza_{Y2ǎɒCgnad'UoZ[AFS^%bX?"{nCmm f(3%qm&ݳpQD%bb됑1,~ww}s=l~K=[kM~W0^eo{Dcn/|076Ǝ1O~O|nV2ckxVt:[ չ=%ķt:V %&,M`0nWcVx\=sTX~v9Szcc'Y} _[~\TDV%bb%hk?wgTv= =waׇ+?&o{3洙mn::+̑cǹo١yhe<_;so+#*/DQz:dur+?^uet xPrIZT |~3%FKߦ4KeOIlgh[]R`l%v֔n6s@F<.~H6.ڡ"&[9&\<õ1?~x[8ߜsq:C Re)frj}>w.?0bˉickH*g+ɏ5xgܚg2w WPg!܌Mw߳33|^[ˍkh[T'+ljc>~rC Dm[udВֆw88/}QX7zM3w-q5K{uG;"#f ʙ{%zh=Lj'k{SKa,pi]WTyhVd bk3E#$]~!П2۸u`Ϟ{cvex%MC.J)5fNi1YޝNyݡi}sC~n)%]wh}znYUSA&m-d[_[޾6k)9eVY{z:wAx+n7dOt0ѥ,e\ n* 3K n\vi+?U6wv:!s%к:`r %~+㿾9#Wׇ?"-#M] >gjAf?'BwSud<%eOտ}djq;qAסX$ylƋ-aOl{-j6NsY ~d[oϒ*ͶzR-=𤐤`$o2dw5˵uiמ|rڐ0 e۝0k\Vʏ䶪5ck6^P3#~H3s~bk<~["R|vףd> @|[mR=ƻ]PRᮤ %%p7׻=¿ޝ327&ǩ^Wp\"#B"V _ c+Ek-dh ȝ2|rVg8*,L2hP ޜVُث-K- #]R颠BZYw$)Л!?TJw3:,;w7DyהwwŔ%[,MIŝ-ĉ񗕑a}o"`loz^u{~Ch8@iGu|/*TUUzRL1H1ލL+3ou7ԑ`<റ5%PV}JIϙVB~40V|oܡE?|r2 !?um7\BZ gϹ,Y׉~Ir*dV2Mo^B񺏹eӆa 9e|^1[O|%pJRLU-rVTBlmp2R[oeǖ["O[ڥMvE=C ޖo7iGgC}Y}%̻:^Lnʣ.)K2eeMv}7˿! ȟbI)5v>$9}%ӿTKS,&2rɌ/*߿v=yGŖB+opN\q?$?f/'#'cFY^\JK &Yw7ߖm݈>=7iJg+WNm'U|~y?:[*%[8|[ck@Fn/X㈿}lbˉ_.;[KZ:z)= Bd2S_d97kfzs[BwJv?#1.1gKz^*~DuCfڸ+bOɳeN{5s߷]w̒L/tNA K d&s%K7]O!n/>WeI%nfrU—_*tQhse̖_Myߗub837Ll@lpH}=SK@)I TX\Evi>DT4nϹ>vyL7aLeOO)eMd Z_dwDBFBD?:,7?ޝ>'iGdJTyЗ ?)Q04WiyxI|7N)2+쿧S%߼)(]Rg=Բ?y̬'- YsW~}x.=40ծ Zk,@0ʛub~}Υ*%\&sHELW߈>ߞv u -}?8UZ1t9a ]~%K<AzpLз2}+S:`l:ȭ2ڢ:qlَe&}, dd NRfA_!C2\2Jߕ_gzZ}Z?וWW"(/CߖZezmĖ:6[_c-_Ty 挟=_Vi I g_ʂI T(L_2=Lҿ}@LvWF~>d73Ez,_2 //(S`lYI R6bV)D?OlAOnzq[`')%Z)'3$=ux N2UX! 9>Oן*>aF>W?g!^L -kWe rڑe$uJٳEl3/XG zC٩r)rIQ y0/9r;\!׵_dw=c .'<~rJ_|;)"nGF3F[xM}g[~ SIM|ȏn'r$^9$2g.P/׋C%J}zOSRgʬ!_{}z*Sq~bk!#w b"[ 6@ Z L~ 2Y-eC/3=)3)3P>DW)./i4=lt䯇uI"B0)RKKߖ]t?䐑El[-kh02M^rY)-HVB_f7{}%#M$O UP.OXJޤ 牌{+Il[֎`W#`R'sXN$)AN~BXZX. )He3k'cvv+_ӳ~ SdWeLz2ȯW?a?5.[:Y\FZ⮱Cl_+ֲ~زߦ>G9q,hN+K[&S!BOUTά\1d*C~I^ev>BP0駐뻨KgWoezʫ2-3A[RlYV)cmO?M^L.a^Uɻ gAf^fA3=-(Ne_Ke^Z .B'kof*LʃO!}N;dkg՗M*_PZI7)/ 0d?K//-"Os^ZB霾]/)ߙ)sws$/{~'WͿ.-$sC~Y Ԓ_cS U߼*2=]UBv(cv2Rk~bJ[:XlQBC K5M9g{ĪIJQPfJPſ2*_fQRU_UbW7/_`NAc-r+~-y*?XY+,?q^SȝD$e{Ubkɿ ulY~yQO=d?܏K~C92frɩVe)Sy=pUW9^_׿*/+ I~U*4_ 5.pEiݡN2rYXՃG/͟:lUeᎩ!#W#޿we dzXЗQH@iP}k/ES${Omɑfw~?Xl-[JMsk{{`Sݐg&&ztrd|w*LzBܫҷײװ6ACSWYE%/zeB)sz[1h}IH9{d6gl߿0'B/o2?./•vdK@qzl^e-D?Ŗ[S.z2:frdd)y+e//#(yA=4ɚX:DF^eUnB[D~α[ֶ$:pIf~TdN $ӇoZ% ץ]` - _!.VLj-[ Em[~^Ǻbf;n~pHu|O\:t5kn%plp%ǞL/k-V bҷEv}.g/[I[[zyAͰpH͟xUN>2ౕXF[bKF#iK[~!BXynd$2`%b"6\$P6~7+Ilm uBG`D+pisA/Yed[P-[-#Ku/m:5[ ^ǛL{ؒ -زRƖiaa6j_E_>*JOSZU|7uQ.@D" bKbˮ[iEm~!O[TBD:2rO?>)bˮ[[*z|WW5\d?@d&Ol[ku?D?"ɤslg|$gw D)bˈ]b ]Cl:"eEx؏yB/!#utab%?[l rY[{HU/-;}l]N NêNrC#L-D?0e#'ȱeV۝DL9~(!FV$1vfAF?|luLlm} Ol-m[e@'~(5Ol]);B#ub >1p%po.cPF!MJ$질mBغ_lm8J+$É~;rliQ&pm4z6OdF{[>>4 [vN 䐑5B"Ɩ%_@Dic Olm:}?$z&ྼdF#l2>V1?a-bp vXd?Ӊb?`󧇌\,/_"_-bk2# >XLv&neO#L+-2[e|^џ0N+#jS g[;9GCG'yF"L5 Yuhbv%bk7Il4dc l Fa&0٦2R7t cE\ pnx#F0ԳHYڢvE2ڱŖ%Ζ(-bk >Nxr;-σ&.l׿X&=Вgl2#G;O[Ūb˒V Ɩu}$f&=`:d\DO8DY hM џ#7XBߺ7RoCVpA8YVnSfo1 uge*uYѿU ֦;g!`%{{$/e7Oq!(Fl*'Ė N},8~5wȈMVkX¶O[{;Nu4i`?el|uyo.wlA#kʌDe##7]ӧ0>֑Dm["6Q&e ckecluO 'IAnje#IزENOol3& T c˖7qBu2b~uc ?1?ADZI2#Oy-pMp-[S.q!-bk<+=~bfpkOя0zlBF[J+:_l7p2< ։QW۪m:i,a*מ^zp>%&?=dnvD?5i臻 d?$r>MU5}&#?l6rdIl-m;~b"[3w>1>F?\!SgOE[Mc q po+|N 4Ah;ozܣ#, 8#ǖ6E?El[cp XM*E}1/Y]!]r/iӻ}-|Dhdˎ[:]lMWEl[ŖOlu/~@w[8UN/q􍊌2rB[5i>[gN `c'ZƩ2?f8W^^dFj{ GCFʈ-b"cK8 6[%swe7so_Yy!LYGjTdEl[G-[p"Nf SK2 Z *z.Bħ, jф|^0N+#"-b:b'܋MZNse1տL$E3#m/"LiԴR<;җұE?UefRl߂37ͭpW+_s. Wz2rv#"L^D??nu@[Ŗ-8q`d?@ihKQe]s|忬?wu!#'|D,l} hEl[g-[~c!ALol_N NES##"& ['Ė-b ø@H5ۣ-SuW%?Wo)#WZ^t Ē#nHbk'Ɩ;Ɩ[ofS-8N r,ۏ{]eۙr7oxCK%&WeO'#mx=UBбe[Jb떱 [,fLwG9Aa@_{=t]%wo]_{2ra"#O[";Ɩ;.Zd/p>@Za.7=mY #7[El[ͷUq8d?$J3K CY] "L6 Il[El&&pԲf>p0}uou IDAT+*1dl'#m޷ :bˈ-bk~ˈQi߼` `>^טf[跇53듟U.F,+L0*6jK -6[[SV&El6fo[vZ!ǖ;UH􇵎Sx`A@Wf=N)듚eF}tT³я\0{^{>Omr[s##Y~=~@72wVm"ns uw\6J&h)[/Elm7/ ~px%]{'0Ya%![HbK~b:O=en =yY6VkOvk 7*}TH rsxۤn-VLoqFa*LDD?e]!߷غal٪uWG:CHoigt np w#o^~ݖ8g2e6-:i-J[ #˒ +l9:39_6jjl7wEsO@\3p$O-짼,2,]'y+z/DBG6zEDџ:Ԣ6ߵ]2f=;j?A,pz\gK.h~]/eK?_3 I'쟄+&՚vYqw-ơڮ3-䛿_N6 :[T}W)}WegNLO0s2 rrz8ܜ*_]Z:g\/_c;KuӀՒ{P[ˡ0Y|ӷ~m퉛5l9I|7/Dko}ZdJv~׸acBB d?IY&ݖK3߲ߔeW[-fLgO!_ RQ 1YSKrMּOt5em9 {iOo[nݯZ j۠Im{ͼ4-ܬޛr;b^-W_d햕?ȩp .Sp9I^kDV[W/ow鿟ceG;v@^sH ~':fyGo˘)렙?-wzx)d vL_,Y*o&Yxɲ$35$=-񛒿)M>."ʰK}o}H D{qmZd~_;J{ؔ+9kRֲ͐~mgCP|n%UM eCR*ܣɗqʌuʠ[E % >DۃVʃJfǎ8:mǭl ||8ڲ7%-Æ2+ewÙ?ޔS* V=r~n-ooJF5jxnмQf"p/Ƶu="UB"5/, UPs[L*)CmTT CAG`DbkEmI)r\1Ԟ˽d?6DKki5?^{'oJ}9G0^`%B-CȎm}dxw4+ fls؜]>MRJHsׅCkFY%B Id W S!!,Sə9dEVCֱ3uLr]#oOH;~_iHL2]e7߫\wg3g U¿^syWz#_Ƶͮ׫rw}QNkt4f<=!?}\Tσ} jB W(X)(WBނLR(7nvCFmѿ_ls >o+s!mk  tp}yr}i;K뽥+F{IK\('g&Lfl-~߻=},*qk 6S7Ԉo};h_Vo'ͽ\xTo \(+S2WTn I AAA3yW]2yrMpα-K[Clmcio\]jr7~\UVYH~ _kWxK"R]Ýl~} LAw`2䬼H$g&vnflW͞JsDtO g5nJ72UW _-4]]|\ գ*,THR)Tn*ӊ1ȸYQ[aS[v]g̝ޗњ~?d?AKyJ_?_|j]{ꓯm>);N,pжk[eٯBEmEAgGIə]Yęٖˌ菞 `aeڮI1?6^Y8VKㄩKS_K#j (iKEfnmޥ}'>mʴoϻby_0'\K4{BPe¿9?シy9;W%>跄@G*#-aYUGY."qȜrS+s,u:u=.)FK!A,<0<[ʿyEbw  b˺?1%S>6wb{0[rjSΆWH~5n{TOCG=szK2ٷsKuimaٺ6tAPgSTK "~ Tn7v1Иh?G=hA}m/ mjK;GG4~5ck?'mo؋5+SK A/ɬJ-j|9vzK3J\v:A;>G'2׊)sK-_Wi<_A#W]ߟvUn' _s+Sel?޵oO'fܟr3VbLǎ7{?,5'VR ~ vN\ks.֏/E0'c sub037ٳ͟EJ29\V떢>V>kOqfSM)?41?€xGd?\') ̟R|kw}ejXqSQio:Z01|S[4cD&'6#jvyLnj)oo߬!tc} }zoʅ]ߵs*.pp9(.`5s/N*H%~lh)Xu1X܆GOjS[4{~[M4k4 Db}OZv w}cFM4$c޻X7Vkou`}ޮMHcܡ}p߯iURq14hҩ-%ޑ&p2'MJo;w+bc~]oweP"dv{9ǔ.[eoOYXd?' S )%ϧ?gySWrl}ԝO--m{;e\”yNd%~2uV 7c.hȤS[4cD vlTY\=S-Tz,6g>rt;U?&,9>?&cP%3ӱW6 ? cS3%VY)m=>^2_m2L(-:ÿOw]0t1O*7|ZиSr>l۬̈l喢~3,s o yMޒ߻lǐ%컞o.է9H=d?=SKYKd{*?'d]/GB!N珻Ţd1傥>%~=$|*^-cv#Fl0b٢6i+KRY֑!_ꯠfH)˧Cۦކ߷2U}2kX_Cwʰf)KG@JpT8䟚?d-YvLm}Rg^)2}l~lR}ǮXR]oWxOWUV5*X#0餉g{o۬=DmVX 4;8jO)!-cwI6{cޛ*wsz.?4=,#hoN\#^˔Dvm꾿KK}}q e2Rx\'!\OgM<ԥ~M~g(ݿd?pLK{~3E.oOwew 1*+>,OY d?-EV}sH1>SCz:ij-ojL#TG^WV~o>=7UfUIU^VJQDu?Db˒unׇD QCY1Iq]H-jcװX5l(h8s_[L)JSɑ& OޑayĔӟ[?E8ũeJE;$Y2 mtz[LRvSG#Mv-Z?ՅPգ4'c[2ňsy!-sH)¿]NO5Y]qn{Gp8ͿjDOLJS(vL)OXqkZaO,A`4u͹)lǸ!\5=ޟNp)vG'JN:DF.cyd¶,?Vt;!Skmn+}o?E\06'}1~ݎ}+?fY?bI3HTYKe}{1\86,}$qdK/X+>v.v5C]%?Gj*s:VAus@,f?]VأDxMdO?B\q0!F+|L +_7nWU?7$c2?&eLi,-#` ~؝3d!O!{V'_?IB|5^{5أJA:y'xڤxc~uϿ2Z{=P[d4/TGzS9կY̼,+ɊBrreʝW!Pso&O53ߕCIvuA'sAΙPSzzKTyNm&њvT6O:D\97}|ν~[D !ߕ?$JjC9J7kIs=Al!.J ).HuTSS;kߒG8>N0/pOtL[)cb} |O7YU~cɭۻqj]G9~pK~N(hRn~]3:Wu|-%JqkӽB)d7K5^nSK*)R~v0!'ߣ#, bpw6}%MiyByWf)0-Ր:0Kן3}%Q̬}G_6s*f75~=bj_o{0&æJ0 o&򟓱O?]8Z6Pޖl_"cK;ν`;K/^+L z 8arjr< ςym5ۃa?m?w -ZXttJgOW_vUE}aBV-e͐G5 &S $+9W){Րut&=~K5R.k{fʱeq7дKJL s=|,WRBװooԸ0;a,|⽬V{] _h\ :>nO]0" RpŃ$}{xs1?'Gsl2ӄ~ %qܒ,[ tQ}"4Nd*~\剌Ip̎=C #F~֝kԿO_ (]{bt:65f],#}:~gTo.ƉU¹/ߜ$=TG)o~DYۢvC[D6ۯZkQnjU6O,/|ץZh}V>&5~}YΚ72PuLET9Ʌv> |? ǰ]Æ2>7tyXY?l 6eO?d?&܏K5-v GX}qG6EWyp*c)C^c/w f_*+??߅S4Af-,ٿO-k..#BC8T_]C>ԏ#+AOnW#_~>w'lhl[~l%iKv̰7[e}n-3JY}{~Kc[5mt ǯ T8>}J3˿kdޯ{S^+򽎌~~DeF/ncȡ1uql惼2W=re>Uο>i/FioxU_Xؾ>2u$ sJ埵l'9_>NV=*d~xqc^Z#ku;޻wm+e!` ~X=+Sdw"̩]?WåsodY J]"dS3;? R7sęgIgOSON3،obqsn%m-%QcuuTJlUNbf=,Z!7ܟOn[a`Zkm[v[>4/=;4_]8BN*JlM9H}Y}fr*\,#{+{*o`# A1ϧԏ8Y^@p4 E=|$/LV}fJ&_)ߌ.1?_i~? V<0Gf +#Oʹ_"z|/0W/8=r9[}W4WҿPK*yB=D1N:<3g%uQwyXƟ='7\v}h H,o5`3yf҅k m_sܯ%!AGџ|Bbkؚ~˚.u{ R{,eYhM`\Q} k]T_W{/3wz/E0}U cKĖ[Ŗ6#^H[wqD~Mb벢:\lM=6+>쬬B_P+!!Z7煫~WYx=f^,S潼Cz,?b‚i!W&?7VLl%OԌN!>^'~ וV^%K_D%rI+;9c/mh5#@.c윝Qo~bh5Rys%CX}9W@ nI;yo_Py=rGU??yWY?T|w_YuvFf~XLJWNNYȼ^xE3K Ny(EnX4^?4d~-Mz ,>ޯ/%%M_c\mu:IlMgn0-Kޏغ~ Cwe?7~WW7{WfCÚ8IWyBfU5@`EܫJ_+3䙞VRP39ߛ'kߖ]{boo%cۚsg'FVG_S%/Lzi^4<8=4.ޯ%)BU[~WϣПGB?yV^$2yc﫿Ou5k/}f3S8f7]=AG%n2vX~sXW~-}X ˭oZV/^DZ7E2rʈ'{%n[Wlٽc OlmlM걵D?Ol}Xns.u</.:%&7ޔeAG?晞UgLϢˋKLE(o-P}Iϻa MŔo>GB=@ 'c]b"9N{`,ɟ|Y!r)7W=z*rAN6;=r?e3GFn[tl^eUnvlm!U Z|X/[[i~b+qr_x]ǛǀcUNA9";MaeF˩*BUg%yOwn )Lo>ԙߕ.ߜ^1o{'0LJџ:jB ?LR~OU||¿5V=Vػ*QN"!f$ViSD?;=DEPX[FM@l[Io :8S/K[fz$s  ^Epd/}e/LLc9Ujw-n5A!?3U)?$'Z%e{<7,~?!ӏ2}eLO(w~Y 85 !3ZM:&[@bkBn[Fl[WǺ͒ IGu8o)5Ĺ㽏>/s A* g^)s&\T=CIfl6%~ݿ2d?$=13}}(?'Ji)?PIL"HT^nu*߲U2R?Qѿ͚d #UOlmN{)D~D[m1{u_ǧqNʜ)&MΗU,._>\#+'+Q'/,2WLL+oK8 Zҿz d?D}?E6o7$K_ +;ʬ~s2ٻ\aʭmDK8>=p;_~|;oťdSb ѿ["HlŖ[~#Jcٮ:~Mvd$ ddʲ?~~L_xLނ~Lw=*^_H|~$r;ǟ[oQ c&GM߬}?=J=>d? O]K-2ÇD:?(K .UW!)Ƚ]q68qso O-ܘS-bK~bkBlhu$m[HD?88wNV.ofoݵbI=2Q("* z晾~|yN%/0j ߏ^΅.Bo?=Zbys[+Seۏ1i6P)1_ߔW'x}^gCeO?a68XSBF[Nj-[~"e-~bkVc>xqnQ7|,WfLUװ޾Ka/M??4 J bpۘ5L @okE2ь=W9=+\Vtm%<҈Z}bjKthrmF ȳ7m%~K~o?H3}?HԯWFp;%\c |e[)Պ[EwwS?~?Z4Ez?!9'2:v%FX5i&|;op[Vx60P ?+4#iFFR[ mڝڊkFj&Ҍ~j:Qz̔.~&'ٮD=ekc*."H@,ʲwP\ 8'pd[ic(~ZK ~Yu̫aB ~B!7_迉M,nO |Ms|X3 Lp>=^lڢd޵GiF?EmX[_>~Q${ۈzƠ 0]  Ufymџ0TNj%\CK 9'Om9sRHqlߟ5۔e0(*8\WJ!;4#Gm:hFf$EmQ[kf=l! IDATB3ڢh+S!I?_ՇO>MJF(x'>]=ߣ￟b*ug yeǬp!ݿ4U;Cw?P"A%U0XjQC7uicװ6hF<)fiyig`jڥD[4#WH@[*<ڢ K ~~‡8c{+(e]V{TAC;stX:~jWU'CB>;w NWAuPbP=R7Ku 8TۗIA-c➣h`jڝڢFuemmaD&_N-=Ommڜcǎ̓NTim*suVq"߷W^@t\H'XT(!ÿ_W1'Oew?[jJ?e+j[<ZjPA弛5'&{6#5_dHjz-jzSW9|֖:mdS[ CF|izO6doY+ /( iW]cևEU7izloohBgBBˁwe􏽿w|iC"D PR `pSO=jz"Hܧaa`LӺi eImA[:VgŒ觶ί}TF*M} p?9s6J m~1AYYc?T ? !'O-DXkF)}(_(ejLM_&mMMT Z_O&y&hFj4#Hjkou6i E:R}z]쿊}|)`ڰ *[s`A<PN0/;ϐ߭#bKO!ǁf?!|R-VF]0`PMlW-JmM~(Pϻ{ D׬[UWw |E)jf$ُ&9 Ѵ;3R-dֆF?Em=?exڎS/U[]v*kYVuh"S6/cW!cCB>Kp {4cu[(*66ݾD2O>kHǚx7IuLmQ[3hcpfm鞵j&?1q>ޯFWѺu7I恰:} c*"ϥ-M`bQdEw[kkw ƢL~}r hB' DZ~<7uP܂Mo)WxԵxKOd4#ohFR[mi?hS[sC;m=p;}wBF?o``L޿K(EAX9+ a7L1,HhBO!>аv`bjpLE1>H_4F58\[<[ |Iۯ??dpQkŸdFo섃0//EmQ[VB$C?Sjڊ_j 0cF.DBΊnr]M~c*p,К 4;:ߏ3ꢥ?!rډƢ"E"A jjiR߂U\_qUJ>'ȵI3rx{EmQ[q Mf$hS[I Cm"aGUk)19fA BU[ԡYV ! #k?z(ܿ3Y~YOB>Bf|?7U{ @%U0(Ţ 6opM~Fk6ӌLp`5Q\iSGÄڢ4ڛ5##jFR[[h v_86iے$2/t>m>/@F21g2Z^]z5 :mw#]};}PPtXtrnhBY{ :l{3\N4PD-8\;8ܤ1K8jQE՚, k X\3cɹ-}iP[oɌTݦVlm1̺+:ѯ'@~fV0wK/ D̪&t4 !'3v"\lmJ5݈~2|x|GM5 Bk+hP5AP[umь\zz7GS[) Cmi^!t^ '0pVQKBՠ PUy]F?*?'r^hBٺ{ X2"h `p W |MH` ͫ02rE[MB3ڢ#k IHj+%EmάdwOq cxgBZ8TAd;~M=^j&>0r^hBR-'n4h#CPT&܂U~"p&0M$6pP4-jʻAOmQ[ϧKqC:eQXj`P~YJj2;*E-!lnt{k]UwM}r\hBȧ^ ̍+_!j`p X,q:)_J1Sی ҘsPH-j,δ/g+j ֖R[[hcJН/ͺMZs@ԨPx ZWS~wgLO! ~B9@eR 3wp_Ju(aQ 0sDFr>ֈ*Ia84<2HF<ğxe[WLrMj \*@g/*-PBž^u#o LO!f?!| e5>_cnWAQ Pkzpa!0u0~qhF觶cjFS LR@"3f Ec{p)oF`qKO?4*e俯*oiQ[|.i%Ͽ=9z~箫#RS4Fᬠ_'7+]BڴceS) ݺ !3O!>0?*UDCZФpW |kEiם)6HfcarHm?4Ә>~@4#4HlS[VdMl4cS4pVU~*T p0/sd?jX~w{'ρf?!83j(>P#[R/KW5l[\w uV;njk#.um0:r\4 _mF k5cY԰FajPkKkejƾFٚݨ_cc{}g.OO!'u#_|cҕu#S :4obq W84cFJiE3:hS[;VF?!DÜ&?h,T]C X.RTC]^s3B ~B9R?%?fN$T ~:FoR6I-Ww8vi_w 5-P[봅v-@[ۚJmE>tImQ[I6~տiZL5E /D | TA! ۴ֺCN:m0 !f?!|brfXw_U!AQi"5&]P¡C|5 $˳nmaGh+UViO=OmQ[i >3!{m[Y5>UsP:(k@:Y~EwQC͍OeB~B9J0RTwCW/\C*?-O \[_`>MF-kF?h6FH4#-$n9Bq_އs .5  `  P5"kӻ?eS:B!yO!GFL?+hc!(J_F;\÷X !f?!˘(βCcWu6A%| cM?Fq\E3ڢ-j:RF?!ARS**wk 5P(,ʿo.K\w;){>@!O!{$L?ݫ4CQMs _`q -߫G7r"~)BI m8.4#-jڢ-jXMB.EwBU;&em Af 7S4q!|PWtf2R?G+꠨Š&naN?0jaB$~lO7Ж O--jLRjkm'd+?y榤')_u\| "* Q*8H5)Cc~2BI ~B[1csH4!_ x6CZ[#^O3c"c?rLmm-56sd #2I\[iF[ N%ufmomשpmF?!Igl6zKIܙhf/FmEk VEmQ[ז&8.șkbL丹yF[#ؿm/dpVPИ9|2`!jSw?GXtPw|0BBBԩ"}rCM~EeMO SFO?Nh_ZY[4#-jڢEȹFe֭KmAbMVQ?%1 XTA>Go54 !$4 !do6CQcXCO(Š ZġG!śi_c&Tf$EmQ[Em1~e;>~(SMU׸5.n F1uLП]g(nB{'tnjN?ٯ:(j1p WqoQ"v+~cgj\F* $J~*;32p|# P*8sQ>iBH:hBHnhχLwc)}&#qg G#c юiVI-jڊ-Ml:yN&Ġt -4\nV]_c\.UV"nqdPFϾ3n3B~Bٺ!곱~w%h_ĠT:\qG o ~FCOfdlm09ڢfjK\lVc樭#j+ѯKÌ$8~k1qkxP 0Awasmt7LkLx+QTM+_abP{ R]I32†x$SMoufmڢk+5D]!0A*\n @U bDw5gۄB^CBD[fğUu`p WGZT4uiFlp Hjk@mQ[Oa|˧0Nk\5-k/]fM|B ~BITgCM?/Tp W E%5%I0S_ahG-jk@mN[YfPj룴lg,SېC7\D3˰nÖohx ~Qp5nyDwWE2ߧBAB&{ kO2UAJ,'pU E~odXW٫،4i jڢ-P[M~jUo-=רF7Sn{cMʷ`]h;)15+\"mtD9O|ȴaB~BQ^EO-7Ġ8R78KRzP7}4瘑ytۣS[I[-V mi''=F-XÍ*UxP_E?u QU3ʿPo?r(R6M|B ~B٢[ `~7nj~E-M `f8A0L=EmQ[Vҫ7hS[іY['ydFa&}WQ+Z "eoS} SBkhBȎ:kM57_i_/XT4gA]I*%jڢ-jSEڢ3k켱G~VRԨC!81QcCƣ7hBH,hBHnm~]QEE2bQEͫF"9Ae"U O--jLRjkm觶jKjK7~B\ug#_w{N_f]xnRBš 297oShBHZhBHꎣǶ7'w7(šT0;D^ae| Kd@EmQ[wש&8V AUQ[4VYu,_Ӟ>f\#jbí 0*QclM[4~j+hEC)6rۑFnLQ8+j A[]ESusN}7,j[_ !d=4 !dmsQ]s6-(G+Ff"|{{ԥOmQ[Ϋ-u{-!5٨9OxpVNA*\ؕfט}wiBHZhBHNEC rwU|uT?~ۣ7m觶>Q[8hS[+BmA[4-Bȶm勋1 kjp=.Fi 07ݛCXTt!dC@!)6FO,ID{S+:Q+jkCڢ6Ϋֻ:hǑ=kĄRcf[,~g|;).Yw_fck* .㽷kh9SzHnrNk-O!}O!k)"'"A%8\[=Q¢JϹ#lcFo-(2:-^@jJ-N[oЖ"d"\[N-Mp -ri?dd`G~}~|Mn=N!~L] o)UzTBABrtsg>+__-[6ߴQf_C3f$ -j 4@mQ[V:m9$+6}FѺvLS>o  (\ Tjgu+~*Xm\B> n{èW,J[KcWjQk^p(a#Ds "/F3 EmQ[ ڢOmQ[-Bv7RŸ|iU8+p.k,iqDeXTlOYO!IO!+t_C͍N@JKT~f$V|EmQ[kk4#-j:HT:qiJzaVhV~k)j(,`B; C)b?C@z BohBț Kd1E!(bQM,mynT_㚑4L-jnP- :ѯEmr‘huviʹ$;)s?uvtL]igL_? UpPX4EaXZ6U*B!O!)+u!Ӿs(j5(&`qSRjQ0#uO2A=ᑑI.[jsS[KiFFŗF>=n)ܷlcݺNj㒡-Q&{RܸoИ_R "C ~ȠFE?B~B٢<3ZfN$вݨ~m&WuC 2sC?M?0n_f$-Em][qLm( ~觶έ-qcݺ47*_2u:b5D,D-jq(GdQϾGEߗeByBVjtz5'TT@oq SRH _A<4L^q )EmQ[IvAmZ[9 ʾZg\|'/(k_.5.>[DOJ?31emB # !$Y{}YCv*N&QMR-npS5H ) 4LhgMi+%D3ڢ:@m|gTXum~upV 0Fvߔc< !$/4 !$w7ϜKD Tq&+_lu`EAݨfnɒj4#h+mf䩵[iK?[[45j [3 {/_uk^؝5F :Ը1,)ʸy/ʟBCB6$c.j1,JlY~'_D4i6)qC0ʼn>A=g|yHN115uOmQ[ITQn-x0m?[[$4ޮ#hghsyH|i]~c Qnyc]|o\߇Ax7+!']lD"@-U' 5#ck;VdӌDdjڊiF?rDS[I>!GH+4ދ5">QX(lk_p6yݿ}ӿϞ BH<,!mk}/I+կ ڤP¢E=|\VDoQ[EmQ[{<|wLm@[9vOM&44 /}͟t,)/!4B٦9e/iHyl@%XT[ {aBmQ[ڢIm+ aV"d?|3pVt 0fs;.nO!`B_lW go+1ڿXTjZ@?k(ɪ3#_Adڢ6;!jKjfi f-ohD1eXW7a3IopWQ!`fvO7 g*B~B43TKRqx=R nbQCZ4}%GRFǚD 7~K]oj+`I>N[pmoL[vpO0 }S71'5t^[#ؿm-XW7ʷ'}MxsM*/8Tb jؗcCvɸџBB70|>mNE} ER;OF+"]_A1#G)Vtmڢ] VHg4d*h4SӤD5"@Ԏ1tDFw[}&>!B)DiE-6&MnQ¡I8~fzaBm!)sڢ-jy}')_I2˗b ]Šsq)jud,?af9B9+BvhydA-X\7xTmL&;Foq>ֽk+W|lʩ-[i}ڟR9i+1?%kmXFt ?Jy#ʽj!.|D,PgCzy_UayzN:Bad?!L9LS?A *(pS$ IDAT6_Uui]j4ϥ- F?~߽ 4ωy%&TߌzeNmk^O.~y>55 p)j\=oNGTB~BIٵ^I#{,/"jPSâEEU9Hhs>gH[-jڊq:ڢ\~{VYڢ=qn|gǍ4Ս.|i" EU(\Y@6FwΩlSZB# !d¹}k@-ZjQ"MЭO32^hЌ-jJj 4AEXfMIM"BQUx+(\h}  FM/"m;3?Ÿ!}O!+SCi;'Kb~^j <;0wWѿf17L4}WD[v,3c(EmE>0#mEqN[4UHΈI<~GG̺dݽo2@a´S#vt,qnd{FBHZhBVT[&*D@%#Fp?|H_-c"cM ȿ [[4"-jڢΦl,eF\m:&1ftGbݸĺbѝp\nr ܾnt\V5)`Фw vZTbj` mү8]2Mko觶ImQ[}gg3k쒻GophГ\ÝjJM1i>{5tRg/K!}hBM ~*֜[ @PE AݑaOk yImQ[tD-$=*()iT'(|!(m~߻%4u2i*#xBH,hBVMTx^I%MdPCuٯh۹uۧaZ5GOFRjkmь-j\[YIB4mpz<|c cq h)ҶGmpL]{/ߊu߼ [A|x`!0q?MTs"SOBHccˏ5̇^ #}C7aPW׬<6湠3tP9쓬OY652GmeV:N&<ɿh1j 4 >2fAhz$Eʚ4Ce|~%)hkpk#**4χ}yB!O!:/md?PEFWppOEË,Ci4Ѝ'h EmQ[ihn}F"p orFf+>e>|RYw5/w0{/)ȧtBgAB24.J?(4mTHoތOdh94#Sif$EmQ[EFX|]cqOO!yo km*1<*uQ[BȧCBiL& Twߎ?T(A Z12hFR[W[4#-jJ|\?h|bݱeX7z{o勼nr}̜JcJp)tX+d4aIض!BHyS߉=I_yˢ91c]FoEӖF/RDL< H޷3kKn$F6O퓷_|؟c]&kA<_Tw\np.w{X.EQ_D7l4:[??e!FBHqb{[6E MҶݹIÄF' j-~jF~ 3O^z_\F{nt.jΚ4ך?i60h",Td;K;~ Of?!,۬O?}Cuߘ&N_iZS\mO3JQBmь-jk[mڊkS[k/oiSNf1@x>Hۜ:엟nS]4w _ v"]?#rUwA "hlWfIm%) jڢOmN[d7Zz'ڿYM}cT>c*~ p*Rjq;PtfBY! mO5c՟w_,Zԭѯkh,6FF,Q 2hfR[umag藳Fu m#s2&??<&r̓bՍ{žko˿_ @$6wQD`on~׭'M4j}{{WhBvW`_DTbQAZL'a}K3zkUyiyPW_BэHj+Eah IWy[PVLJm}}11.̈́{ܘ9s;OM%mo~WuumkcjZo4|̱w5Tg1 L-f?I' :Kx;?hM~R,*uO'y?˼ѯ<эD[J[ƴ4k iKsJ4m`i {t>ڴ҇K 7gf^ەo4׭Kr&K<"- ଴ier:б~n> {B! 4;GӴSM5E4iŢUE?ъaB3Yuu[jK-jkȩVS9;4&oSĈ2zfG ,G/џNN]Z5c_= _v)Q !4LhF1~j:hS[۵@N-4-B7'1S$G~ 15:GB޼[pNm_m%DWMy8l B~B wԟ^QZ-*ĵYٟ8usWdixҌ\x7~~jkOҭ-Vښc7_k+~jkmcr73VMh?灄o'[E-I̺4]](iL~o '(*/M?>OF&>!y!$= %9W5ލ|u4#O-J[J2wjm0$6/%>r9.(~hc:l2{ӦG7FLפ:=YpD^?f5~+WImA[4ۣѿ(eF)65!-煚3Pzi"Q(Q#~d?c|+###hQVR[ݥtRj:)1M`-{ן4NVF#<{Sxj?Mtkx#)FO!Gf?!h.c*F 1@)բC̊I寠ImA[4|6Kn4Si?Fk yv|,ߞgYs\\c{<4K[B'm֩FeD 6iK8԰[: 8~}WʌW*h=-P[봕 R[c ѯE(Hyc̣W7ܱ{T?tO?>o'e^Զ{6(oۇ>5.Eo k2hω XZb'xBûODzF?ЦA 4"r>u$YDS[9s?Ũ|#[\>J[d1~;}7 ?6>6>gzsѺ+_fihߦ59eH\| R+M3g~ݥ0<}uΩMt&rOXjwL@Դi *X԰PЌ\_1PЌrjs~jk@me:QHyZ/4?O[ג7h4sSӷןuM糨 ˴,mg25 WkyEӆꬩHy_;*3B ~BIQ=PSO>~DiYDc}uiZ7JmQ[H`Q[ٵ>sm鮴Om2iN灀WS.џaAo鉏~6_PQ8d;Tcӈ'u'7zٟ?] K`h =WuiFR[{-jvE=?lZgOk" O##@X+? &<;'zcu#!'}OL?}7h ~i~4qI4hFHF-jk@mN[Jm;ߌ uXKF?E{_Xt1ϛ~4$SmD\I5~^V2mQX6DXL0'hh~=5u%!g<!Y;Ӓ'P߉~>ki}#@aڙftvoFƞ[jcj+\WVmiDmuU֣5|OH`bhD܍{^^NaaOBJm)\]\[C.#(F?EmmM[xE- )y_5 gzzL欮tܱ&}l|KC ~&F+h/q[;4{.0HjksRjW]R;/hk #r0k+4iO*1ڢOvD! ' k?ǚMFq~~_6џ帓ҵ<яmyqaL  F\볡/(~B!O!sYfPZ5u!cD3_MG0Yl}>f$uBm1Z}3ښ{^NOm_[4B~  KE\a./RB6qc<%,M>eƎC ! <q95}\uxjߤ엯4#ImR[4#4#-d~jk"@ap +F{JSX@{(QmZS>x|+FYL`zNFB.!sR~OߠF;&hTl4#g/)ImQ[ ֡E8YH͙em!V4-j3<~N#B@߸Xgemo$>nu2i EVG֖nY[viS[y 56U"қ.pNl0(56f J]z5;9G =(lh#iiF.qdьHjڢkKARrdm觶Fz"FXhvk^Q?QcSw^ͷ& iDC?}F41RÙQ#4nٷ~>Ա'~hBHF[u5U WEZMH2?PfHjڢZw' =􅡶@$EG2 ߛ4}b pVa-`L9YSoiW>51!B 0wަ)?wۮ]^`7Q!49hFqPf$EmQ[{~1FmQ[ѿm#V^J?g,o>Q!քXYJE"뱡 ]uغrlƔ)!黮CUЗGDӌ^eN/נ&;wVvG'ZN~NtN7Uutm5=~P}^.Fُ ))Zn$2S@!:cj` xtO IDAT 8X\3czcP[o jkZ3!딙J<8ƔY疛ښy'P[4R4=a+ ` ~?f_z1 ?(D },T7\5f9=$9^:2Fְu2Ҥƽ$uWwYB # !$SL15_ Q &X '-3F4i. Ed[RvyӞTj 34֤3@m'K"M~iH ~ )ieft[oT<#oFbd .褀0UJ!74 !$q㲯#ߵFP? j^iw8Ӵ&ޔfd[ڢl;ImmN[9n!-jA4 4_)R_!1ߘ`=E i&԰&/ɇ4]]u S`|zuhBEP+Uߧ6mLݯl4#9;4-j{OҌ~j"hEFzm?=nJ™ilgX>/GN$h2p&,Fb~ 41TB!O!߶'ڕSxLdUP7* 6#u4E>32yi-jZpkKΚHW>A ;5-!@0h~DJߟο+@W?0&жX2?a p]鸋bhF0:޴OmQ[{ߣlO-q)Go;mi+y meCkK-dmDB{g$7|i5ΕJ1ٿ|9Օ}Jgr{mV{M!Q~2_cMc] ! 0Buvu,Z*5D+Ofi觶Ω-.%hM[ym觶@do^Doc}EbT5&DAَ;v[4ӷZm3i!5h #ݓNnWɈB ~BYS ~S{Ծ1aѯ4#iF;j)ImQ[ ]ikK jڃڢO 4;]C#Q8pHJ|;-(g楣μ~F6֩s楎A![f?!,Щj܎O1%F^?w'a9SVOڢhF觶hF?~)ٸ4П ~hm6~pű@Q:lA|(qM)Z+_86B@BwǦZ)_+V61#f:lˌԄc 4jS[VH]\[9.ƌѿ6߿Fm?ILVbd2DL훚oٍ?&?appEʷ2~gJ>ΰu-^c W ADwaN3F:uBHBȜ~Z?S j<9~9:#OmQ[ˈk;;,s+Im?m8M?j oR[ z%X˗Z7r[]O>!,PQJqX.*MByx !$Mg~c}N/ψ jxpE:?'$Yu`d$ck ҖnF[WaS[jB.mHC!@i(A)/ _}Mda_:TWL'77Bs_FzW:~Gb~m;uSD|~WuƬO!g2Р\rOD<|ԥOs߿# 4#iFvk UjBmF?0YP46FK+V|N5}5jHB3_ u ԘQX+Fȣ~ԵY*!l Ò1׵w}PScd!"4#ӥiOm;זnJ[9#k E'"!?7MoS3X8+ih!N'bM=@Db|b$ְ&LJϮ!ahBNؿC 1x<Ԅ^ٝ@32Yѿ@iS[[LwUN֑j"$F_2hEK4Ƅotcݷ^5c$tyzcX> DGak8@a< ":-CѾէc#Ӵy !d\=<WT.32'4#G?+Jmͻ1m ڭh'-jk@mmN[Yf;Y_EeRhDJB3/_Ucl0ߘήxy-Qo,ʷk9z(s+ aML^uՋScBp<EyL+P<ĠJ?џ҈ǚ 'a\zTm)\]T[fEbFVP[)ֆEa8+F)]Dl Fp QQh gR_H,tN|TDaIG]VW(3}:nl}V~a^iJ2۩۟ߌ%Ew}Jmgt1o.ѿiGyϩ |dM1 S7vu委Cc]wMvM!l FBZck EPk+ jx<jc%詴E3ڢ-jSF?2!_QEJL?ϥ~}4f>[OYmoi7[{ Jaol+ޟb}jD?!FBNؿ} Q)U!_4 OߥOÄImQ[EmZ[40Ҥ6DpF`b7=r>#4V 5Eŵ5onI͠hkKImY[[+i+}26A[ON7@ҽR[#0o۾ӗ),<9g5L3Х͛c?Y>io,U|l#ք~pu gS*{ U˶c+hٷO3rڢ?I[4I%?V[4i25FCtkRdIޟ(ѐyFY(0&/|˔F_T"Ӌ)kurd IW7wCuwUBi!dK'oC}W q!1}r@|KFn|#kK,LF?EmQ[-X\c[y. +_:;0>?Q8ujTEtVaP_xNquVH :aB!4 !dt'~^ P+P#FCLYH_F|睆 h/p-y/~j:hS[D!D)/Q8Qa)6ieGrcXCOyi`Ic6ԑSBѠO!C/7~~ݤWZfSVNۡџ<&h+ptL[3LtBmhS[^[3oumREc?S W O/LIᯪǿh a[/_+vmuʶ[/ e]go| o\snKo}B2 cRMJ!gBȄ6ĆnWy~1cs3t5Cyy5y7&ѫQ[I+EmQ[ᵥ QQ$LLRkDH_@!i y"S9F뻡cR~ .Q"ۘ"J?hyi*!s2yaw{mVK9S+f7p"J)&iVB # !1]OaUWE?}xO?_O44#'fdjmڌ-jkma m'$%J PWd+}?F}0Ƥo&H -u6_i'op_͵?ֱb(aiRR7?~S> M#@!k~Bȡhh!55X+3G9[dBs lC44#'ڢڢh+GF?9"C** 6D#0=F |;?x!&/kHx`io[Sh:`[SC!ҟդNlԵ5q !MaԮ:S4x m_4GN0fFmь( EmQ[{V&+~r. .F1'l 1{=YQ8}w g4[Q#l&/- Zl׏COg\B6&Jr1oec ^~MZYr&iKGdkj2׌LݚJm-P[u,mhf'C"/3?E CQ&Q8EQxVĴة궄JM\͖ ?ya[]Zn|]+3q &릤ΰ^!'sR='06~Ã&G:΢-Gjk=mu3|nI[Y+@aW8R/Qm޿ÏMWw.D:w~i lt `[ ?;mU<5MTGhSʩYUƤ_ !gU[Zzt[Jo~E>_0O4'h F~2 -B DWVqfi 7S+L_SE#L~~-4Q%G4Ӷ]hl4>ӑaO39QceBƄr*k] g@ oR|š:3mm0EmA[-j+II_b*'1s6s 7U=DzF|Xl0٨^8ZɐIo[T]/Gա|pE!gf?!8M~] h j6O^[k0eHmQ[{-jʤ-BRb8ѐ*JXY5c# ~M=&Q@P#D{x'49_"<©0L4tEmQ[KU(@mQ[!i" &IHoB.lw2,UQG?DȦ۶Viu|[/_ 5(o$J?.]eB9:B7:f^+}7a"?h \Ǜ6{0I>="%#hQ¦9.嶴fu&l"d @k[@i?: >]51!}uhW}:]_]:@94 !D 5H,4t\&vo@ah#Ϡ"#8џΠ=hK-jʧu]uEHBY2(J &wqyJK"L)=ǥ~^ӦaUmHxaJ0 Fq]jCӞaB^O9WnF#k۱_ƿFU5Dxu3SVaBd4-jkڢOmQ[h2Dq1U\-p1ƼM_zϪƣtF am?3F+|GDbd?D+?L\R#pP7yϋfz;aBmQ[\Ljv觶-M[,DQQVଁ15Fѻ&{DpΣh~~f&nĘ慦`;g]aB^̄r6j=:jv  6jVdP*. uwiF4O[aVj+vaFR[V`E:tQmac49VREeʽ@Twc>j(EUYZ]@s+TRVV`}=.Pj+wS[ѵd3-Eˡjkkb69VBBeiNP851QmL)Qཏ+x(Wgj\JKY,ckLݟL:v۳#ՇYN‰šq) 2&D'>y۞_C~giEcT kjDwgB[gʟ:BBHgcqcSQʪGʟFn/cݶakFڢzjڢ~B VҤ2mHew5U"yX}HLA~Nyzm( u31{xW}?qTBȞO!#6 d}\{4#7aBڢImM-j:rC4 #&F_ )Nqs!}aQEpFUԸ5n~տg(|,_oÚߘ޿?}F3T5! mhBșIۦSlU`>]S)}DIŊי2i<F:h 洕ef Nd=_WЖfچ<Bo:zhlSB) 1okFڑLD[?a2֠-j+@mmK[;4h+Wu"$#@eUK[IgCYkz~z,k8;W5헹mpԻ9HAnZ> ۇ>"iuN ٟ !0r R~.sl *[Qwdߛ1#HhF. Emh ֶEZB[{4qT&P9c?EO3կ\ [RָV.\Uab״WFiۖFf}(D4oR04:4Tv-U6VB94 !f}mtv$Ds߫ fxc#ǥaBmQ[me~j8#k(2!m)e|3 CSOկKQ5.UXF<ѷ4[knJS^-GiGߚ:1 fBO!=O}K^PCbߑԝ{90-j:hS[ִspdm*2yCKQCYg= W*RpVٿgBgk?X 3~~1㵩!x !$m#SUPj4T]0Y#U.qF\}&֠-jkum)hS[YI+hF?!N\/p"V`D@1tףc#UYW7oR!~B6ԧO4aTwd1#X'ɼNuצum͛rJxsp#yj~g!DߌjJ (lw11hm8Q8Sj"]ҽ"*o5Gz_Sfy{hwKݑ_e^G$dm kjW҈F Z]NR!d'T雺O/tY|L_??~(f$H~jkڢOm--觶2jF[dPW8Q!?D?~;?Q Ê5 [RָJi/џoNֳF/˜hvvӳИ']S@!5X?OGHC=K*:@mS[4-Bc(cUpu!#_W$O5wF"U]`_M%}:DBrc FaD!S׿}ߥx1 :rtR6>o࠯l^~%a uA4h ~jkQ=hFEH~Vq/r+_:isCƭ|~yZ9Ovb( ߮}LQ׬Cu;!Oޔ}{*WdyoUݮ~Wԇk1Ê3@O)SD/B# !^lc1/NۜɔӌQOmQ[ɵuh3F:uxmZO[(*\{\K4֌K}~Ϩ~U҆K{i-_ACjגF2)m!Ew6|i Le:6_v!d'o|iI#D'?kWIägMT-j:A[4-j"$?(.VqqM4H_:0 }(ߕYQ\`WeyX"\[?sG˷֟[(4Fi0Hq5`]r'1o~$xG`?G,uaҷ& jڢrT-d~j:YDQpUcTe_&1!m|o?7m{ը\UK`W١iT|,:;8ic gj7>SB~B)&o~w}+-4?)ތԄEartÄڢB5 کѯEmWD7LSW(h_?gE!zR( ( :X!tܱ|]|{G,f5ΖoIhҎwF>MeUO7c'2}ߺ!:. 6ާ)`^m>gd}DӃ&:Χu[ז"|5塶6-]QߙPVݧƠƨoIrfhBMʹ)!44Ҍ\:vm&֦B#% C3ckF?! p1S++ BPQc<_?[QXSE[@U~gFmimZn|k+)D< < PêBQS}O?'3ax !dbyۧ4gi o:{aķ j Y -jkRjZrhmA0/k@0L?&}>{4}_~g_=pKQp7Fb6]?wmNs$/]4]?OC!{f?!d,0Kϱ_7 ($,uN006HjZ֡j*.D_pq *'(Z+W!S4/Q:Kꁪ f/^k[| &rSw? LBv) =174T_co`ߤo+阷Zu0A>ӦIDsi O3ҵ~jkٚ"$'NR[VK'pF`o@]ЧTbģ!}/\?K9#ʗ\z[BߜmuC2}n0&ԗo%D:ztj'VۛBclcgD,bIqjd9橴9.嶴kiK->N\6EHn@% FP ^BP5&.C󱆿އ^xUԸ]k@YxVCԭ̑Y>rY&&r)]}z$9b_Gÿ1R–>Ew)%^ -BB>4CQ}x(ZF~O3r0iF.r)i ~2#i[[9IO>!hEk"Dew235i`( [t\`߯TE }ߍ.?FU(`۰ TW !L0B4 ǬiСiEq?yڴhFNY HjڢN-VR['!1.Fq5!qŨ~Jߕ_?%}?0ƣt5*Ey6Dnkb+,|~0|۟O9-4 !۷;S*C xE%d3#dՋ4LiFR[K\\Rjڢskg9mvsN4Dſ+BK'.MW4}@H?][ꁲᬇ1KLŵ:[/zΦcƿ7aiOH:5kJS''1# Cv5bFcAYt0jkΒd9JmN[8r=v-P[ֈ8e`B4D[f ŽPJR2 l+YLrֿMQ{n!,< ѫꈹ/odu]91%M3Gzk9kKcNΦ]|Ә7i6,E O!lu~w۬1uK;)]4iFy~~j+ekF?hF?!PjG\bDac7/?)DE$ᅰwWdB:tu\FiۖF /}1vh~2?;J!f?!LjoI叝u_b0ݨ4-jڢΫm=jDQ*nNqs@H_:k>ÿ2ߜQ5nꁪ"$jA>a/q ӊ:?||"݉BBnEo5Fk a2K 5[[9EmQ[~B61V(.-pE??7(GU3h?),~(dD*s)&)ӘS[4#/0uFm)Em%=_V͛^[b&Gjw^Rp+U!(aM1FYԕmQ 2Oy8oiWHc͔ ?y8öq7_ԝѕʷG}|?e03L!dk8BY7c:1|P|S;U3F_I䊏M=W9E3CQ[tkbmkKr,M&?9(VR~{+Rp-CTX3.}]/ DPT0=JK %,Uam4D3#kA>riݕoVga2_v Hc< `?CTyE7ڿHBO!]9i `Wa5 FYR*< @FuʬMiKRcR5iB~B0a:f8ܠx}MCe( $.W-j:ڢ\ڢOJ_=be2c(bD+mUYe9 #䜴/ Q+X?ToErFhBȌwj&uh>)WN9,{iFpEmA[4%n?4 ?@4VDe!(oi(H IDAT~v;j+wbBk\eQmw)˗/n5Ø&?1 n'nhB1__Gg~&; ]LXь֠ meC-j+؁Im-3z܅Fb_q/^f\`[+LoN?,zTew]pvyt=hw]鸻(߈mu&V:kw+iP )ħr$hBN|c-O!>~$Wړ\Kw"Ȟ]ɬHdۆ K#ڢ-jhҭe|^G[k/l' D`+V 1 l3<2߿(lKw iEQؾznl?dJʗs{4F<_iԨ}m#+ʿ{B9eB1tC۴1+1?q'aHIZI 4#-jڢ&Fsy4'F?7`߭#{%!k2ǤYgk\]=2fؠJSϭePݞFmi'oOvB?YKBoO!h'% oяhLYSSg_׼[D^\[4#-jڢVV"g!6gH!;(2Aop-IHoFPe=p-U?_~_gTK羻p툭oCeizV*s}!w>9f\=!tCrzLյmx׵13_! uۆɼfW9 ):WU3pIme udmͻ=498.?7Q\nטVDT(cD3o 7 Uߙ_?ܯ.E k^fTdN;ls~so#xǦe64C6tN0G:7&m:L/ BȖO!3uSJ_0i?_OFR 4-jZH[4w-dX*јw\KZBkMFWo4<.e?ZteaLJsFijK_:"_zL'q'Ӟyn~k?|S 'CN}W<ŦojksS[Em-؆hhO(2~oϴ?,!uY6sC)E{R>p~ i/UšUޖCdoCmeBϔ&}l~G @rZH0WI;ٻZ4L&F?HjkڢImQ[BڢO GE!@eBD?WnLRP:Ĵ~0ms;UKz^=p-52Sل"h^JoC{ #-RS'}v:6Tn *!?RҬ6N Mzs,ߏ^jf$EmQ[ ikkFj2O-d4)+Z7YVr 'ϴ}F1\F;AKQt\-D_/h{Na;Ki5ϳ:3s{iwμGtM]n|#3_V4Naû߬GSB^' FFtAZ[Iuu6:!_Z[I<0G:tbsR-'I0Y)nNq) &&Z5۩sP45t5K/ji:6n_xnv;-tNu#a4W!Ê7zۉQ) 1_!\wFFh0~jڢ @ڇɇP p ſBq+{EM4X+0Fc{&nܯ\sEG5?iF4F4Fozcu3YL2Ǭ!Gf?! 4rVi5VO3rCMW) Hjڢ-P[i~jkem'ńhS Ž /E0K'pVbO]rF1YEKn.eQ'd%y2 u5"?ڿ5y B) o-:{9BYjF*)4# EmQ[V觶6-BCXQ8V_*9h "w&o-i~'5RԸܪܯ?]~pjTe7zm+`m;iN~'EcraAW4i {75ajh|p3RWHcN$ڢh Ӗ&5}heTFq1!bW\]4h;-s_yn7?Ck8Q.kLD( Ki* OzV8˗p,~K4%D{x\{B2GH9LvNCת{4t{(C}Z[鳪*u!b8QTFq3hWP9ABbG{q)o6ŚӨ|}ۮ2hΙ#}cmPy2eR" +!L4}D?B~B#>6U4(l'}]a`E4ߥڢFj4Ϧ-BG _ KZg$5 ?V3۟{Q発-D.\YWf}62i'εLpLE遌"!HBE[]υGB>AB35U#>uD>=Ҍv-Li 5-BVh7W(W (57iߜ2ח_5ʲF|FZ?zoN"￑(ļ f*Kϗ!Gf?! B«k8OKyo4#if$m觶rN-4-B s!ec[2Nq+bD)8;ῦGaG4fz,j8[!E!Sٯ&L"F&H?OhBH74 !FG:ԈQMTNfF ckNҖ[[4ekjkkF./%6gS[hQT pᳲ8Bp)Kw1e(xIm!u?1u_T\bD_OeJʰt͗/uas[1یlJL3|>-߿@!gf?!#"i"aßҌ\yhDҖZ[Lr"voK|Xm-BR`Dq1Q\Hq@個 (pia~o*ߙ{Zb Yg_颿K|4˳wK\\ˌ_vI}]Wh+P0R>+%S![f?!4|kZj:^~@UNw4iFېfŠOfw';RXLڢ2 e Xh1.L\W  K0nnot~@%ʗH|*"N i<7iBxhBʼ"cχse1SmPմymښ Emm_[|;^8*[ťnEH_9r0(9oWNi~}0m迖/?_VaT5ʢF_(Ku[/ !;s/>Dys3@BV?`}?觶) SFz;OmJ[DbDaX L0oEZ1mD e"+L{5UQY8||w> +qRiB~Ble'htס%eFbs5NVQfښ{Eq>m ZWj"sRg8h?߾[}[RG$@5ǰUI#*~M^^(*Qxx@ƞ^[rd+=V!t\>_iSx>ڭw+gN}McrajA*'q="}vJ{uxޅc>!'~;_~=ֻ-ǖk0X~7ѿ -fo[ۯ;ܧ䌭z/RV%EbxZhlFP[%mx 8{XX?O>7Ev8ֿi/á9:T.0Bxg|]yߴ;-wo׾^,lk3b/C8>:: q BPB?0E+3s܃ EUE'd&F_G2EH1xuZ NS~ewV#0b.d?ղLO濞mU,lNiÿwEt.X&z_>/E^6EY1#?,~rC1O!kAO!~X3<.ey=VPFn(# -0[͎3[ZTlmgl/J,0Q;v07"+U6x{}̷$c886' mݡ:.ob6jNH闵yWz 7@"!æl~Be?!>$h%PF[`l1[g(AblrI7#`@7@ef_D ~ _6R/;?4Q4h0_ST}l!$iDa%wKOF!1O2\0_ƔJQ/]ȜBџ3*.Ќn2Rw/o-B6᜹o\O^j++\y+R_5ǏǢ?ΜEO_:*KߕVڷr+͓;'o6R\TߤRVOO!ӡ'"8R ^u:kodn-3McK3═3|zl [O-ViE ~/Z՘o^; x+pFYgʅY{./@z\oMv Mÿ$ u.@D3V5ȧH/L k#c߯2CAZ7ג2OB*`$jWJ?!['Qd)Ikid][yengl1ِ^Wh-XM@0P(X#0fǬE)[_5š>P(Q>O͊_fש^+?s=>!BvFggz|A3uk3"ck_[-!WFJWh,ߍox X{2`l~!  k? ?%cc{|]=[s*|);wp_^1 He0'WOOBJ讫:@)#AvKblQFRF2[-U#4#I#<݊ `px|*X2.,],?<?~~-oeX C[ph~a$Te_(J{1*4PQ=*O! e?!%(4ݿީ^MeO/oo[%(#d[V!-VٱE+ DKb&I~ [-P *'pܔS%?zLcA=' /I7U^TY:oYV}oɘmMۗ_GQ_g?BւBfБs<]d[o(y %_mcK[ieeCDҷͮ/w䄑#Z%1S2TF 72~g; 8+&D4<h/4,9+ۡ1bF pNWlpEYY׮ 7-d[)m~y0+B~Bٝ^k8U(')#o[ѭe[k&"EN P6KEJWl>e[8+0"0F`zEI! ϧ{ fi俔'U Dާ}ktD(Ljn#垄Kp3O7B^B @cZ,ѿJ22[?BF2gl}~lAKT P$-*kR~oeiGӆ8lq3V'/u/ 9;T>Ͼ>҅'Ôq t$@( !d%( !DIE?eE?E?cbk4c-c EE?Kx 1ܤiVR}>l,mwD?QnO[SI9eJc4;ס _keM:ޅ}lwOؾ;Wg$~?!K'LM?K~S0]Jd,Oџ}2[>37ѿ`*Eٍd5WDioT>, 5gtxn+ cpMF%YTZy~俞vk,%PCqc uy\fBPB\ޚ<嵎xcck2 OܱlZn\Mު(g,%ERE}Xw"Dm$,x/0I' Xr?>5Odgb䏇Y6xڭJ[ӡvs3XO| D0 IDATSX ;J]ArZ^ oudEw}/:3!BZPBΨ^fSFRFNxi5E?c;4{)Eul7ˡ' K_~_8ы,~3e}/|+_ԏ">1W%ϕi0w_XWo|%w6>]VaDgSg>P1=/+>o/c\ !dW!2c}}^5\^(MV 8ckZeͧ/kG&~P >pž|vI_d_w7DE>fϙ~~i?QqG?~>l!(*ס~qqlOc>:)a6lw9E'C_AƏ^ikc⾄~BY~Be3ʦKF}fw|}u([5b?$u8|5M.|>Z4J?*&Ǯv|~8FnxG/ޓ^7I%E//5z!\fV?r~Ѵ{? geF Q41oon(\XEn/K/U/UBBO!tr2iǶԥ|_ѯYbl}Wݾ{f  goK+I( OO/+~1VԱ`A ,Z7]K>\{InjRAμ|i2 6(`fT?-?彷^nx&]ʟM:؜ph~ql:(`m1ig휴rN\zrwZW(=>yCF'5'3cW'YǙʭ:u'.g%r ʋvRsi.9-& 4f|"{׌h< ȹX0:_.+ZGmyS͵4n Jȱw:o%yDxyqr՟{>WO~3.IQ9KLǷ?[{>Ee_XۡviN/ MuBS,ʇ`Y@keSoܷl_1^2z(3Ǿذ}H diB!y'Q oߔ\< 5E?cscK~ E~p/),4^yp4r)M_#r!,1uuu߮sQ)OgY:Lk? MکAE~gi)e_/1kNJ%s {~YF;ckQoQ_|۾װF!+AO!;FdVY)#3gm ̟c+F5.w藱?~#:\ٌ, ?VesI _e`*{_V+^kDž|^xǗR? we_bROSJ?{`?~%H5tl>PO~МPgp\ϵB>袙~Pc!Byc( !daѹBnJHP{'EOclQvl^De rK~ƙ⚔N_u}Qo?ys{wkrA}o ό<O}}eF[.2}ax,vPg:!C[si][:e>}}OG]ntR@Z,N^mZ3~E-uK0>;PXliY3Ua& }a(oF{ ^8Ox.Jt\/I72VbЗlQݐQ!˷ξiyKYIr9}8zg-;%3޴y%IAP?BSV愶>ؙߵrfܷh߄yuҿajA*'^Qve5]' !e?!j~Dffa1Ew)Kܱ[|g~]/F%p X/of蛋k@2Gch3Qmq.ʊ'0*z\ "oduv\$WNx9'L}ϾG%e)?~V6?=Nh(M,ꄺP.f[MW }^iS}^Tm#cFU.rtB~ „e !3zu;O[P>2rr(WCNm54c (<^8Qx;Qx i3/ 9U::>JEU[KuBY\M}I}M.7W= u{J[)YGH?_R&Sup.1:\Iڗ{enj]2}=6Ι! e?!P_T:b%3S3J ck(%rIKߤǦQ1{׍ O)9OQBy,ִ=<[pϕmu¡؞pT9Q8.fSV+Q[րן>x҈,Ih"~B)˭cɯ_c0FOUbk( RI,bV\ e 9gDBȧE?W?9"i?y8Dp]A>2J~k*Ōcs±J}~DST}l!ۏHIC4fd`BZPBHcfY)s>oQ ͸5kSevD[[| 4I|K8I}/{g<ͤ)kxl$~o2KE3( !kj{)WN{.YkHמe v}ڝPXNhM;US_X9k˝>zɝ۷Sw)߿E6Z+%IjO!3!]M-^ϯNѿ` )#[W9:U/_ބlA~,ï0F7x3S e>!urӒ~˸\%G!(/M,ߤwC7gcFZwۮA>uwFzaRX3,_lݠ}]^Keye@BAO!;3>uvQ+y*ѯ[|([.2-] ~菂EmjQT&6TFO_ȏ)D$^sx2@zFkTBȞloZ?uYKڟ%v?h?L~kӥy$,nP>}O͊_fMiIxRA,/BւBvFUUe(2#ά>PYѿI[`l9ŅyV`RJ4&&~PFT_={׏ׯzO{%O}+bv\dߒ|b~[pLY?/~8'x7C&Ù҇[QML>i/?֝HR!vU)c|FO_VlEѿrl2?&҇AON0폒x,MF` 7d1-{n=6 =/kҖWτţ,{)ED v|/;_kqh~ֱwRlW}>ҙ%0GBJPB΄~y៧Nџ~qUBa;־_" 6e{ pTM77\n[E?<%焐oeL+sMb?H/-h#߿.&o;xסJ;Cnm}B[PkcJq]V]7?fĢٷ?]д53.Ko߄y,}18W !䇲BvQ­0ْ-A_%Wbbk@?3&\*_K@e:T& oDoMFkF+7꿞~SxB=߻${If$f.J:;PWQW>*\ klc wMY^,߬}+lM >&F ;cya>(B49)S3?yf[ @ѿ ѿ_/(D%cZӡ Q?dk1c߈$/2(~IGׇbB8j}T?U9?/cc[؞pOh*f׾/3q/wKu~7E=P FQofݚ~dV?! e?!^v3 O6q}\ߜS2*HoύI#џm@m*Qs~d4Y뷞߂B|~.?w߭Hߓ_ !\L{$ f{lu8'/~$xKY΅VoF4>x2 ߛg L_: !<B S6ئ_c)eĖ } I$-26 h--PXߛ>c /GeM{=#o\)g%=${ӞžH5}k;8]MFhbV;>il۷k{AA8yՄ (.zGѿ f\*s.|ѯ;ۊc33 wP$X?I_d(Xs揥Ϗ{9sk?U*_yn9ϪX_y; T?Uߚ(+PvCB%+n |?EVz[CڷrKo_]:k7@WpC!BO!;#W]hy֝\?0x/kSF*:rg>,ud9ckƬ[}VTޛ^} X<⟚?~kӝ_{'B6ӬTv}3D?~#ol~2NhM3?* Všggp=IobKƜx % ڷ:łj[y}:vީ}3ķ !e?!LjSOdPo!_c!%Ry~ hm@c:62QT6~o67#o/ӯݺѴ9Or~'g<|sSM}~,&~)'+:7!5R 2SF:utyc* +X&]6K[ODQp]^L["QYSQ ~p1N_w>vji+2G*%uCs±ű9Мc*٫c`-kmkXe4'B!'1Ø,LSSFRgXo[eM:X((?fI7VQY7@e^[B|2SJ_OYGIO!tt~wIZ+sq~ #bi!e FaMO;::T>̾7!Eb3B6BvFνV2Re)#)E;Ė~Hl)c (=*Qx I{qG Xs< YsK?Bʢ~W?O |;S9f^IG8`m3 k$tg[;xPdO|@}a}Fw6WwZn;>m_u`捏:s^dBY~BIqWWpww5竬}DrKe ⷘUjʼnF:4&<&}k55Q\_g?{<g_u朗Bl(c_-k={Y"1?Qڻ({'X7@,ieSoܷl_^2z(3ǾF 8I!d ( !dgF;vGџw~P[{VLPx( 8=EoǙD1#1Iϟ%YsN^=s޷n'B#2Gsgr0E:uhR~}SP,)uQ[Me"er))S3+?ØV_WO!@O!;#Nl(ͷ83kדߦ)%~M㉁ P!5vָ(*TV l/cvLO/U/}}!d>}퓭Jgk#l4UV﫳GߥǼmC< o//*QvBȊPBΈ@*{+E?E?E:T~w-~/-N^(ۡAT$Z޴囹~ҟBgb{Ƣ{oxLNYP@җט(Db}o;U@;ԾCS(%t7 sڧ\l6OBO!BO!;3r|nz*>滷{lK xSѯ%6E1}J %믍!8K[WOB|Ϋ#2o:5{*Q`š# k:4ͧxRx.9] .;--ڗ{|vwXZwW||j@%esHD!BO!;#zHУY:3k$gY/=v5k*0G@m hL@c} oČ~kdvV?p)1ô[zx,sdBYw??DצoF+ƛs 6ʟTk.:WXۦc;Q +vhߒ< o׾֥;rUt I:ƿ"9ƟBVBvF fI=+(=~r\_?It8؀¿HXk`E`L_"_}ל&2m9u>E]߹'_s)M׫x"a" cO >tR/16Enj]2h_WБ'.32z Y8ҵV?Sc1ԢhCk?XCT̓{ Wk_:!| sOz?X"Iym@e}%_UMPW0ͻ7XqWl~ӽv*~X6n8! IDAT,!Bv&c+)PFf~P3`0}N/(jIm__:E *Aim!d( !dg}I<o:֥!2RuWk5غO:gz h$`:Cc௬@e{+FD[J=)ڢE!%1e6{^ zY%?ژY;6 ӭ=ǀe_넬{3]#0)|O WU󗭐,jBրBvFQ\dSF?6RSODԯ[hO8T6Dog51s% wݺ~|u1Os^g !ۘ+죯oZV=*bV=wH)c '(Z]ƥ~ ${?d=_"BN))]c~B)%c3?ޞL}M%ϥ{_wiA'pJB _0Fc|҆c:2oBօBf)#)gkp}gl p$ 88Em!^`mx z ?E?s} <\VG?I] sI M\ʜMb~?'~\5ϗO66el!`(gtb@W8)?4D?y_%}//BrBO!;#3/?IFjEm^CFf8([jeG@mtM@c:EEp_R?%n5!Q$ek9gm^r),pƫI'!ُײ1u3{u:(258C2/P[|lrin LM%o~/kϫۜPdޢʪo邦qYz&̫e++>^{!䆲Bv/2^܋x?7Lvÿǜ.ѿJ([W- (*(p0)UTtV`dqFf)[7)}]a, {OBFyd/? (i2dl=hJ+jКGǟWC~cf$r+I}g5^9uvCgW_Υ Ա/ !_ESi^_DqISb_|b 뇬ϯi9.* \V.r<\TX}{+:+_(R1;\L0BփBvDD0TT]-W"(A[BbA$Zb 8tT_b'eǏc%&L¤ϖQyfgwwq!]\ZJco%!r)+Qz{5@ᯗr._K"|_?d{k0ln!W:_rs)kTxMHl^BքBvf (#@~~c+Ol21?'_([:EΚ t%JW}g3:(wbH֌g'q 2Pq[f| jBHa=),:Ȫ Qi_?z.Rrr>j>R}8c?^gӏθN!d%( !dqWy ȥS>Qsŏ[ؚZ((:pjܒXW LG>]|- uY4\cSQ d}-A^5^xn%9[;^#}8o6oBgm_'~ OBZPBȍۼke]e8:RF5@͐ E?PTCcZXEen`}ѿtp M&oMI{Po;> %?9{f$ Ҹ.s"bo=51eEfq_r\路{s?_ 7 G_VRoӯBjPBH^Mgf_]o >: RܯWTKI. hPcOǐ9%lrF_+P ~$}/u$5e{L=pzQ_ oߒӢ7h-8WvfxYA!e?!L,A}/ed\Kآ((< 8ۡW4R[Y$L_>4QFym_,]JGߗ$c?M% w;xd$7m߸FOgBzPBHD>oE5~uJ]efO^/񷯌Y E)WĖltkm.ol@݋~'0vV3oAS/㩒B3]5J~+Ē80&|kU8YM? |kǏΜ[{tOK|s\{d/-[PK:;-wa._F}rUiUJG싵(s5RI2Rf/rȭrs/Ե-ֹl+ݹl=ov1YZ]Cd-ٿT7gVXTwu;R߆5 1I=t^g~B>eo!˙i^H`^8e&봨% N NW׮>#cG3 !d=( !dg$eџ[QFRghL!JllbI?ڔ!E1u>ȝc=|Տ+!mݡ:U* ҿ7VWđ݉c$!E Ef딢8軤Eix}^)(>^KRyZmBkPB?R7J aI2r!ET~}c@Q#Č~T)V'WlGs%8>f>Nhm%SE_Wu~C>!ug@ ^sS'S!5'Q?yF\;2r#E픢UDDQZ_Bߒn>ϝ͟OG/FFr.gTҿְ?!d! #)}OQ*!נ'oO}UYEOl W^-PI@43ǽ诬Y(?CegOWUX`muIW.{ ӽ=Q;G,Ҿ -lt)eumMWKo,sNFw3>n_S5ݘO!CO!;cD5jFߌRKT!2r /:3]`/olYl~p| P[kg+K-?'^Fcɯ8fCee몋YC*YǬks2E?!d>|~tZ7ZZp|er/{v\o3bcȒq~EJ}SMLџ}Cw1d}ˋ~#?׳BփBvCQ+I:&A*QFn}gl}ulyh,o&`DO?77lkJWt +T.f?4#TuJ^wRіώ^Q}yJ(~{}  W,%~B# gKW[()[[}B K&`;.qa$;(e?Ko?O' 0V㽄(me[whu,aއG!sPfv@~l#|uL/㟄BC( !dg @Q黾[eUF+Oѿx.<.b0v-]7 ^$[ڀڀ)j'lZU?Z_Dam*I}g;xK{]CjP^'l;w]z۷{ Nelsc||j1iNmޥ|hu oDM7aBY~BYgg?9s4GWW=/#uCoVpѿZv]cK-p0$[Ql)俕X2~+[L:Nh_x|,k*l90,Ov5߳}*;ӢW|LU_~u1|ɲut{r)WN׊_wU''#QDKf/=(sV09*As)JiI6Xߒ;v' ? 愦ҿߚ(ExoZB?) ߳}*e[) oӾSt~BY ~B M Lѿ֬e_O~,Aѿt_zlm!x(j hL2>Doo ?G?+?U6jH@] Ccs¡=;4u )*l`jVc1E)K/B3E(둢?2nE?E;>76IbQF7k \?n&e A5FĒ'.ߑ!BޮCa*>ۭ}:+ڧ5; !d%( !dk7@SF ΖwedRޚC;4CӜVqZUTNM,ݗ`O߽?]д5-lQT&̫e{X}~xZ9F!AO!;ӗ7+%o&Q |u5{ ƌc o[b@QAqH6un?lb28ӡ1c؜phNh: T.g)e~BHRv6u~Mڬ+zٳR:,]#S-d[)LZ#[l=sYxRB!Ϡ'腎3I J+_[6oM=b)^[$Rϑ?6Owſc/;x,cFOɴ_6/E?>7+_(Qx|P_.EfNJL5W?@!_( !d5( !_ë%H\&Q^uyg)ߖuB7?cl}El ZКp -PY{3_jT'cc{O{BSl~kq*} BNK}pC`ۧo})!e?!-//4(NbbsO %bFA:6f7#)F/1FLD]uhM}J%;u |Z1h h`>+s!\>3<[BւB]HQ@ Cѿh2Ru6׌M~RD** $5pZP[EDg\JEs-Ƿ2o  KPW1?G_COYqx>}-ʧo0hm^e_KdtͯQH?!e?!df ,K_wxϕsp;뒂9E^K!~Nle>Ccj oNQ;JߚK+-ѿlܚ(/ m}BSŬ>)*¦3{^cyүFGn`LRN|lݫx|KFYW$߶I:Y:pBPBJޚfD0}f輮4E?('67[SFqt1߼~\lNwǶCSj;˜ts!_=W1㸣u3&FBe?!@&Jt%+~P1؊Y hr+Lϯ- 0 `]5?mE3*o) !;u>Hw$7o23) !$/2˚:^t֪$V茍mddꔓʝ3v_KϏ- ‹И. Pۀ9R)/pu,h:m|T|'lrLZ| ڷr+͛^9~DzF/};ߏlܝhh0!^T54@!~Bya\Z:F* 9gvC'W IDAT2n #Wn6ۉ~h_ 4cK*1E&6u1Rꏳsd\Bv84מplT?[1;M%|k,or۷`~k,dվcүA;єS?cؾUZO֘rBBOy+rf߯yX_aIq~V2xko'b D2v-SV-.~~(\x?Ky/0C;UohtBUCaGٸeCڷ. ߾IK;S-l}+>>5~/_by *!e?!d?`j҈"`Dd/D7}JE,@ g?WF,cB@~֞Dq0G`.u?xoDk1e/M?4 uZMYy߻;ZxVWs/7s^i,h;ǧcB]oѾiQ%jo:ӢO/.uzj'3'D/C)iBV繯Ue_6?C}l-^%, 8?4N8LtmԋV:8P(~-WCOYkgf[5{nj#أ2 A =ev'\Sf9_ Mf?O |#Br^B)f( ,s72W3/<,4&$kx+p@L*+u|$Co;ԾCSp_;Ƭ~UQ B:Flߌy)0>&>f|E}Z~M @!8g~s1^SSB ~BWj~?Pޝ$GD6(6gd3qccl[Zj3g+*'p0wD=ßm\&CpZLcs±:t?!d>}ŵOjc6|~U)i 1iPB~B \Z|Nխ ?dKe$E*.E?c[QThCk:46(T2Soo;^wwB[EC{Je) !% !}_%}l0(?@2SN'r~BYȒj` B1?IFꫳQ/\R5G al[ /&ގCdk09Vv!DžgtQ~_LxPWqтeߴ}y,񱴟d\V𶦅7j߄y,}(R0+I5I R'K~,Sd"^Wluhw+㲟*, Jh:\.[$Triroem@:uwſ +!3^$?yB7D .u?~BHdGh}GOX~GTF!Gb?!pl!Яeʦ5ߥc~5)Ɍ>%sR迂o)"o BmV' uDyQ[}?w'.u?!$W'_{l~fY_eYvTyX5kǴ/ͫ~b!{'J)B:(BM TU/Jh&#׬նRCLimIB._'O@[)_+OtSC ?=U߭>ϗývUp2>˥a5.ۗ?4 ezBf3okk8wK i9 'T "+BrZJwNʿkVSПQo[Vk 6~  ? mpB$ 6߄_ )vS_t)ۢJXsn lmm_{B[352}(BH< !$wMX)<UNPr bd>(/דoq#htweh_!zw.)B~B刉_h$5תR7ܳ>؟bZ /K!{ $o[J 8|Y܌2 ZaQw?&!w[,_|5VQ'lѶ(8-n־Bl%eFhR3iYJ)bUNԖ"5|߉2KBաO!6]ۆ{rJ)@v]vPNk.YQ_%Z+p8Te K:j%io,`YjaD-}?~kp=CkIq;[5+Gmδ6mg덤 ?X%SQT]\z[%}*yoaF?D7aa"p!P'|9g"WgjE]I;CQ W-z7߭U0? "([}ow'm/BпQnf пñA4GKnP&4!yE}<|03J!gb?! 4 ;!S19ݯTJ!P~ut^~d1B?}ߪo~Mu~dwjuť= Zyq_PW"?!&nhC>>@/ lۗhKD#nr>[ !gb?!ܟ煮!z?F46lR,gH[OB}<=j#FP1[p<}N%[ݠ|{o}wʣO)QFxѾa>Gh_Q'!Я~((h=i$RBHIP'T 6wih70hfr*鯗oٞ&C< OVȐ!ͯ<ǷvJ VQ}~Y |* 4 !)+ޢFKH>>}+8V 7V2CJaF?9y]C~ a sx3T:yB١O!_5 J%k# ZF?/M(H].B.BYI]Ϸ4J K{|ݍ6Я0*H) |Mݯ5#\ ! +U^S4%õ=k,&*ξcU2=Wҟ!k7c o+P{Ꮡlz))BHb?!Ԥ+}~~Ӿj%PN (- 9}+o%]y|iV+_iDE==g!ߥT߯9jϨ~BBNO MBwпξ_,KgNuǧT΄~1gTLӈ?'B~BYQ~'$u4n!HS> +KB?vX#)[^CP+Mgdx00Z?׭'+Q[{զ7Vbd?!dX}9orK\|i CT5{.?f5EfB9 E@"1 šsiѦםF+tZ#(So,{JPk['AՏH?~=o__7{_Wu%F`@EN}>G!dE+^ËEK_:9'_3F; !dO!+jX){iM뫾o1R4{ ^B|v(~&9Q)k#Yj-V9Wͥi|(Z;~߯C]9TôB?!DW#۷yK/Rе<}G3ocENb_&þʴ%`DM/rs(Wἁk4sASSBO!Aqi^"BHa7g'F93y`V>?Bl ["1v0˙_Pki(?I5>}iaýnU57wÍB?!jk,g\xsrAI)ʾc}z)I(5+h{c]vYV$DzQ+ä2\{{u*?U BVZB0B?~mT6}?P|IT%V7];soukp=OПLBF>JNBP/칺DADA)}H4S}=BȕO! 13QD'+7#wU_y⫥SZ + mO_k}2otHޛ F'X8skӾ'fd'Q3hWPj~Kߦ4~XBȉP#!lHεi]t6 ӗ*ҾBBP+{on"T|ԩw!Gmj[͡9TB?!׃؄ /%}> 5[.w,_KcB 7BNOҵ֠>C*௖֥C)2jB_Yַp-RX%)/nZPk}׿LFwu?:jw{ݡB 8OuXL;{P\˥X٥EؠHއX)(ʾc0,޾#wkQAREs !eB2RPn~i/@;ve#Uɔ/8/䥺nUIk.EPNv Av* koe-h{Xk#=NbQcTRȽv)LG쿹!W&wQ^ń~hTTXv}d!Gb?!䲼k } |?v1PP[_6MB>`J_vV/#@_f=*q5ໍ+k9xO}a>Gv_>B٬ )h8g86?fN-kO}>'B~B=Bܡ&irB!#5V/q{րV)xߟD8ُWJ)[#!}?r$rfKya+]RFm1mNы/m:[ut.f[mQm47C >ֿOE!gb?!5dE/_1.([H`0&\` $ w:D%ЃK:CQEk%ʡ2mU;|߂_W OyȵsslN*oaI2Y˳u,A`8b]\z[%}*=lKIWd7p4sk'+HBi(oh6?Bi+X_IPnIs)ۦBxTWS?VͱQ<*qowͅ 97)wП> W) .A>rQm ٞo;iM@#BT}.  @rxr4ڦ"GRFpNklSkuw)v: gd P"kpwq3JF+hgէ[F^;|݂usUU%0Z9@lX_>9qɉ/0!bU6O.vO h{LDKߏ=Zr4(B.CF ӹQcR Z(n~qS|kZ%2L+q?"n:L`T?!gA9Fvi+h}oE{ /] F4F-cbb[ RO<1 }f=R]X3ڶZ ӟ}K('/ҕJ}(Fj-)%z^_?kD[uo=jkO9XOi%c$ӪӾPn'Po:e2ߙ7sw6ek._gxRl* S~ E|Bѱ,BY]cUD#~Z#6}+ޜO!up?WfͅNɅ}+_P)JK+V0Zͦ_?ϨدQ_ GUyX#P'|:[@ھX(eG\c0Qp>2i YT|+?h )/6{ߧO!G2*-k A7%))o_G?o %xf)ZP=7Τ7FMԊ7?oaJ`C KeQ@wi+S?,KgBmX8!!}7./͝ڟBJb?!D4SӀ#i7 [fyҾH7(/דL7o>ߧƄK%;KREG :G]9ܪοkZS 0+!jоCV'.\ %}c] 7Q}~Z?!*P'~(bh._aHR :) `GVV n~u֡ڽe@}|կquP [["!!B!}Ba$ ‘l~ܸ 1B٠Ok{1>]jl?zѰ]t?|/ #K!{ $_/ ?Zj%:_"S Qݫj3Tv9 WO9:kL=V8o% ꍤ\bs=}z.;ٜ|U TH \cѴ[͏)b+ه">! 0&B"X;nsV,"Uޣ<FB?N+ (/2hhZ gT[O?TO-c]~k<~kPW 9 ӟBz)orR۠H)R:B?(迅\"h8oF-6?6{_~ BșO!# ƔO}T߀QUmyHفʅ$6b nP7FJP)Myܴo,%C4Cz~]TJ 񨫐ִi=v>+ 9-[C[X~ ˯(8 gᜅsUH4_,ٗxB92 !dAC1~NV i_OgR_m)۷$@@PV{D?{?w UDgd?!C%V㴏>GyFy 47sO!Wb?!4hM5^T?oŰ^TmДe a OԧMm·giIѥ2T*DߴGՏj?ٿDK?)%0V;|||=nu!%dكڗYʹoYf͸Voe_ıR_o[[AD{~g+?vI䀩BHX!j(#=S5ug~UHݥjIgQKs|?o2-|K+BPBMw Mb:c)9?D;}wք:R"=rP,ߪ]BGv*S)^;& o#{g\ϐ\[(xpb[4bF3 M}6ϒ%P'FB OgEGwiizSZ(.*<.%r @_yTJ6yǢԿc{ݦZ[qV{? ͏ЏFS?PߥXrK  ˅B&пCTߦooL=:ΰp\>!P'cb~>F0B{4d@O݉*YR"56 ^i+Q!cI@uNaGe_Wf='Hb%89ۭ&t:Ex}鿋 F /=j ^LHMyЋ2.{BѡO! ŭKYMNW!bV (P?}::˪ Z& h qkX&":ZkB~> IѾ;f" ":B /a_-75;턐BBfXҐmΥok. ) :%W4X&*C~A_bB|c9YIP [ N{K?4(>{`L*B?!uzǖn_T(yc)ʾ KWo.8}3oKԤ_q;\7 ޷bB&1NT N " `4;}g]Cu].2O_?#Of1rd˟w2?rzTg0%!n@eڨ~S.+V;*Sr҈xrkq5c e?G-?֮7|-;b>X[J>);1褚 pe(@ v !P'ޒIcw ƱbfGkı.v-$ ӸIBl2S6RO\ Jk%Rψ) Xw?ZyP{P[z#ZRwB:2)_sMʔϷROWun 'j~LpxD'KB9;LO! tF^Y[md~#;g~c|Š[z[$c !msBL O:CJ4ԕǭr!q',$n ڷ>aeqv*S^" "NBDm"a},~C,)B~BٱA9FPQ%nk٭@?yR-f"1*7mJ:XvkXڡ$2!ǃB#t~6}m<Y :_%Iͥ$REW!?rg~ g+ KKoz#wD?gB?m)57hţF4s0u?!AB#?TZI+AHm IRSߨS_]^Nw\·fTTM㯻%GTT5CBe=V9ԕ5mtl}\vBГ}r/o[ʋ)ۭƛ@S,4# !2C=հխۈY+S/GBپ2X%M%>)fӲ..R=*D׵GUy. 0!{^ ^Jw"Gh}cpCkD1q~?BHb?!DtԬTa Y'YCj Z)%)_߼z&_>=:B?+JyX: 0]Vg;K:kRaҔ5  +kFKH>UO@Gqxvɔn("UP3o.8uRAhZߋ~ͱ>~lT@UO!%bY3~:}>Ԩ!!hE5~@"EsR+X藽2D_uk,a/?D#JO&_L RF{T&+zXFb,X.} k2=e=x5ez5e=Co+˴eu\$a*x5D59.i- !# !f,ϘUc WJ,@o~ڹu~~ ߀W*xTZ`ue=YyT?73 !dY KݱKb{<7+Wu%EpD'Wh\i_}?Z_&,dB~B$}`cA83 !E6f1 <. 20/=u?} ʾe%F5Iz ?v,3e"XaM،F(R΃ަocNS~~ ˯(Rufz(x1p*<\ x+8i~ls*Xӹ!P'6$?߉sI)oyO$ iux5:d#J?4i:wVҦ"Aך"$!E@!>G!x/P>pU?D ?_dum@N(  `-TUL]hd#r6Dd +{E+ؼ o;[yKz kQ_`瞐lp6>V8o%D6DtDt" l-^g]o.w_V G?_-C\BOR_fieKַ„#ӊaCWnh ! qC]ڣ[wK9x5c?˥a57O/忹cz#i.ܲ}wO<pvlmnCFl¿?4nR͝B# !gmjO7bkWd+hZ1k A?o-C!L:2.빩 .w][z#Э]*A П^KڰؠHځ)R:B?(迬jtgLSw޶i#%!P'Y*H-YjnFRoz鲻U[^ _O~SJAq=gyY IDATT h' srzFw:k_+Jߴi[ߚ0 R~sGV*^3VA]}ZQbx*< bgE1|_BwpB!P'ŵ5S.~EІ#)FR$)Q $:Yj\yz_̀Ղ=&',u!U㴏>Gv;IyYu~Nl~B!/(BB8]{jLڥ7OAM`ai/aiTOy V)K'5{ޢ?-&DwƟcl\i/xsΞ$-.Fie0k)EqwDGoR BUx4SJ1ظؘ(B`Y3|>cgb&6yKaA*bфE1?iבA >] ̤O#_ X#tiL6Kr-xmX$"?VNe*k3;z3BDyƿk#މS'qO!J=4_H :c'/?[4HA菭'? m&J_bbd?!/KǍ<пKS m47p*m,Π (tبKMM(XsڷIX~綏Rt ż"T"/߿n1YB  !d#[G_ъix5KKʔw%G%]9\/շ KQJLh !߶"mmƟj?! ϼeǖn_ı鼛(/ۭI:7('އ4.pb}Ikγd?B)FBtKfDߥGc)SFM|+Я EsSSuA7F`ʆhOʎBc)dB6 cMʔϷ< KwПO)ཅJ)h}ұeƲLCP'' !d!KK1lhg*mI׏IgNkdZ Bs$> G cT#Wh-:քF˯e!e։E=zh_Y?חK8p LYe)%UjE}pbaTX` !7 !Cb"OfMZ+C`vP_#~ -._`k\E$T-3 Z^) 5)j} ۀ>ڷøj}xW-𢡵Bd9#x!٧1 Fq.}`jEWJ=z#xP_=G@?!zOg+Y? !/V7F`zQ0Uuꇅ`_{X Y$evk sJ˜3YEKm3 )R 4_nVqN i2-YBu\r,rRic=@\zT+mVJؚV.#jc E-0/{\S~XՊmf:|:uTJrHCa#Z|͹n6'}Q,<^2ڦ3kQ,OHxnVe_JAϷ˵R??Q8oQ][xрңC>c1)cWB!FBH62c[5f7S o!ߑ7g6^VnTݸV{=1Os#Pߤ\>ޕBBc)BiPod(lkPOV)vϏv>D18~8E}Bb?!|4Sf} 6‡CߣwȈ/Oݿ/݇BeBHJIQܾ}_%ۥ6:x(FKx[AE#!翂;Tg7-E񷛁. FsRuڇ@kF3\Oy h6O*f )h=crkurLO9g=meRRH_oSi-‰Ge!UP'xO!9XJq5׀~׮:2Q(YK  Q/շ4$# pEC}^(xR2= A3\ KKݱ/R@uokYRǞB8Duvk0ֳQKRύ <ג 5R"^߷ C*4GA9iv7Hx1ew-muO_HWO:b~̏JBR[-4ӓl)li/WV[R!I7嚉l~o^oa6?&Oc19hBb?!$&v؆olzo y[G)S_w1)ӷT;(lN )tֻ5_j4~B{.>Gh#gY! ~ Dj8(I?E}BY~BȥXiIc󓙩S6"6<t%/9ПrkfP%+G6Ϻg ~e!AVRHRtcen_ceJ]?쿩vK͒žʴ%`DMsUԖ#{K{yBΌeB(~N?ߒ~Z)VmiAuKIZEB^yR߀-W*?,"Ruc͡4J +v>}5K_Øk,or^d ZbyF>=d*NVYP /A8?3P"Ÿ!gCqj`௃E4>:JBtM.f1Bڀ ?_3=NAN~2$vyПLBB~)B{mSU {'6&ύ]2?!b?!l@Ltj'3c ѵv0\FE4b/p9B>HluUL!,^dm=?LBeMwi"$ke>aѾU >X /QkygYHoE 6'J)h'$JO!Wb?!r6SI:LZbVy1J?cBN` [\yowTv#Cdѝ@N SAlBa5Nh}z!mp5_iSǧI?6! f9i?B9 E@!iQJM PsWPP0Zj!̴a#%9 oއGh#A߬i#kinTx8FKb" !CB0Mg56S6F{FjV8/{Œ$G%!U B?:.m}].Eo^)ò:k-ݾϞmΙ\VVp +SH5kǴ/͒;˃S*x1h\?M?55 7)5KHO!gŲ!d;RB{th!,"4fͪP?6zyaEG1?~'I)[nմ=lp)['woz[[Y!WɁT<˥z5d7hHAkNB-?jkK{@Q,Xo$w_ o]n6D i^dnLI~BeYJmq*XCv1<ֿi`V!AP (>B?^󯿃 S]oQLF $SI|пX 뎧_,?>mN П\o$wQQϣLO!Gb?!lħ?k`_FVV l+Sп8DNСoJ៪Dd~Q?ࢪ_VF?G`۶e)r5LoU*qo`'CBB;u.VgS gZ6RJyTJ`Fg߄B?=)ҷ3j']oqYs@ @+im(ONGV>_1hEL_*4b% x !dcbS3Tcf7m'DEG<i0TOy [ϔK[tU@)DJ&(8-n־l9}lkyꜢ6t8&yQb[mBʴTbE+ E <4tҡ>3?!Ų!WF)5:_5i K  (Q r ƈuVqB k ,H[]ɗ/G{c-  I!k5w9ib`߸ ˽FsڗvzVd6o/W[RWx1pޠgh7B B%ŸBʀ!աTXscJhhjߦ6_>PP_`2BB`BfS755ƟdB&C͗#KsڗvQ_֠ПRrm |+/}nMX LO!%BB6h<~*&M>u'+LO?1w@d1eоߥϕ'?I=ug$HX>f)aBXzUT,m)L tX81?ֳFc#?` BI~Bف% ր@=*-è/ LO?1 i߲v%m䒗>Gh}lÑ>&q5W .ZB^P'\F钔UC/WтJ#J`C)Y;/ M>/BdHYЯtj2_#B~INHŹ#SfcZIaq.yf/X)/h!?|Kކ[7qTNuH7 PpbѸ  65ޢ}RޏBeBHZRQT_J=[= Z҂G_)*4]uhA=п`B{u-d5ԍ/C1gm7~~V,; ;RzCn2#cv roYϐ5{2-rYsK? winx ~#_?'M`d?!l'QC_Qitُg .>(/9~e"+5 U"x),dw(o~,R_.Zރ7(#O=:PߤL)'Zw;45(5R> !d's5n?6(hZ!McF > Vn+VW?~]BxҾƜ2>'0d_9G]| M i+8 !@1DŸBb?!$h$:@k45uo`2!j(N$SA*n㨆@!>G!xh߶?o|M?N '%?SBvP' mN CŤZ4ִ6_#DPMOA{W/݄~[зyBs~X⼥ۗXR}W3#- IDAToF:Kԗ.QSTmGR!UQ㿦_gPQiG=\@!X!Rjh3uD4VթeUhn "_R=.e˷doew/@lSC~ =k,1X>imˋ%ŋ'RuNNzbS㏫Up^쟊I&b) !d='KskX-9'~-aS iO(0E!R'ПK (gג(?(/kSOП?֜BYm phCO_itTЩS0?!l ~B5 %_W̃1s쑔YABa5Nh}%ËyF?\l (8k(.urK_!3Sk^-iO5JοRZ j?E, Y*:4ғ=wig6z2=u=?$}RXDRH],;ee?eǙo9WpyoEȟR)AI1Bò!g4&Jtlc?76 @~~R Z~ 5P#s{tL>#7TZK/;+K ߒFv6t5L|H|?EYu>ٿXr)^+Gw5s3+moh~0TJIOqre(BȆ S }ѿ/Oo"PkZ+;ZjcmP_oQ9'WBRǹw8ПM?Qh3L q h8Wq5W"ҽ̟Zf ]l%߱N \' |lllG6ւZyT*7OS'DK+`,0:п] Vp"ٵv:y =NJ&5On_0N oV"]׭_nSO!$  !CRj /iP۟'[ OB%}׵C Odh}/}r#B+xo 憿UկGK$isB ~Bٸ$5> h {"7*EпGS.pT_[{ xpA [ľc%y?Ct-7?9y]|D]ug5I;ldCt2" ^,W᯻o+7y  3q 5@*E@!yPJ=zj{Li 4](D7)oP{:2/rȦ%p]GA"#\g^CvLAk?B-?jkK{@Q,Xo|8LwK/۷,?M.DCSOMFBo.]{#{gJ 3?Mcs)B_}#PO^ !пf 뎧_,?>mNП?2u)'3)~Pwy צ_ڥƣ:dҡ'b?!hH.om*b6RA:O`Hwyh`Bc8!q5iN ˯(aѾm`yzEy?M~_:nk8)re(BHF>Yj.]\Ko4`bH4B9(S_Y GV>WR_?#h#&h{YBr(BH&>M?ֈ@6ʿ@?{?#eKx3 !tDz ٤>ɚSVR͇ ENb_&?~Z^:#.?;4 ꍒb[mBʴ p M_=ZWpbb{*RD/N 8,B11K/4yL?f@ P .h%hS-nVo.FP/2 tj e rb?Y{Hk,y #K{<˳byzX-HY>ؼ˾\mokKmP_2t4]7G\F,Oҿ) !d_O!;3m~>klZhX XL?SI?K՟Olx ~BArxW ZkпA?}uq6?g[B?R~/mdԽylѩŸBBB2u*%V_#._G?Wߠ[젓"+)h~/0!uαO+c[eؖox1pB ]kg2?!l~BI@% ڥdRilFVj- qQ;(-BC/%}>G m8/wC㪰y61Y@FO.+8)B" losvƮ~;0*t=ƣ=W9!R_^BaJB'itcD8xFEe|L>)\wɔn(R2iq?ryO>ߒv-O}" Ux4wmnx g;Fy`K/Itۜ_6H$sms( $-Uɋ#"w.EBb?!*݊րQ@(E hxY_msCCVx}_V-Y@yD\gFjBf\:P)G#f7Sh>m7rs9G2=߸!KϷeJL<,柯A,Ti򊇽p$Q1֟+s!̃i !d%2ճTc衑~-hW3|MmRHP K'9UQ5пl[ ')SNߥB\| tU 9 {^9 *JFߵ`SB~P'H*c1F>wi`4j ӂ\{#SD .zs`7!)oTf ӄ2}[s CbG"x%rn^H% 򬊆 JQ^(sJ?FS'0?!D@_n7I* y(EC=mSOGh!P7^>+w Jas 6CaS:|NI~o/_c`!`d?!ȖFB2#ȍGniٯFE{5Uw)*Lj۷~oIĀ }o+[7vVV8Dn;Ǚwm6zz+Y=&z$*GK*pޠ)e{V^)Ti{lQrTmd,}* oO!+C:89W0Z!1{Z83C\y{aDB^oYy5#e_oI3!;жo58rssMӷ=b;q{bydb 47{bIcku{5"udgy BdX!ƢFS'נO!;1%UcFooTۢ;Pd_p^Pz h@ceu#g߿$ۖBDQzZGLпG{d{1~P㪫lP;E]BOwp>ARm*'Y?8=BO(B)Y"Y"h*pTWi%̆ٺ\GxISBM"Ea]0<綏Bx?~?Xt6fxV 01uóB"?'E@!ث2:uTmR[FU ؖO?wٷH迄Bx}>G Oa]_ԯ%>0=΀{{TCp!~Bّ)\ -];,ҿS%ȔG*J-~Y^9\SP,=cǞviw)S?O"+ɫDr/m%2޾#7.8tň)KOQODCMG) !$LO!3+sRO]~g[JO5jA=r! (8R":/R!ʢ)-2˶XcKck ITdƜcH$ZT>ľ3ϑ;{ÖG(=}ڥ"'.C3o" />/ !d !$2?T)nh*}۴#^` т~]DP "$5G?6B?~7ߖB?5N*la~#  ?(o(Ua]VN`95EiC}&/LO!1uꜹs,֜QZ+4Fp1${b,h΄gxO'1Pa f{}',݋CË Jt9 D'pREM?}\FoB!?ad?!P:Q}iCﯼdȝ5ifzwjRпo7'OX~>>9GBxC=S (lGyAasEFwx??1ﻹP'P'<00PfY{n:iUZ5_i/FDEĠ@v(UXO~EpbÜ//'oҍ-q7yn(S\nrɉ7Cw{wy%zmENb_ %}c7L=.|fx+wpA>S sP! CB6N~! ?*O 3^VH7 m*XC8^YՔ}~yWd !ӯ#αgCvgD4p41c?>1^aC:N|[aF|j}6d~K[H|/$_:x-n   !H?$w#6߼EEZFx_YXȼF&PcBQ_h^q H!dI7TyQq[v+Q> OP 2ǭ[sE/oCBCB1?e XA݊~ )L )u#ߧqeB?ٍLvOX~QG=ˏwN\#<A^k8t6ǽq %7 1ܨ9p@!Y-K+cWFUQKh @^J5(C 7B? 5 !о@ ˏq863.#F"!Q. !d=(BHĕұYCP*~:?MR_ jrCbetSBx&)!W8-:mk_D6ľ@6 5@d6:v8l,NhuŬ,^b?]ADWbKq׫^@Y,IϨ~B'vrOy&7nu2#R%ȴ Z3_i2R^P*~;X^r3ϱ|9ȏ(N^^A* ˃7 ~Mr^` !m9~֞2xux g.ER>nJTЀf{{ܨ~'O!;Lmo:C~{M*#!w6~ c? 9MT>Gh}u8>USQUߚ7p>Ca/e(} < @F9Q^Q !d}(BBkchۦ 07-kaGj+R(!Wt Bi^B,XV, ύ_gxcNsԴ? 7Ik5 x Eo߄m%2޾G9鿡Ϸpug{4[\ 3tO ЃQK_c}Ϩ~B#SEgBX%~-ȍK%  E,em_hdYF,G(!dcXX}[<"xNe*I X^?8Gz(=w}g~-<T.Kcbm3DQ~f?p<\+ foiTкC۱O!@BV`W'mӮOS:H%HO!>'./﹅~r(o-~/RFzY~R"_ ?(BxѰ>Eas+nn[EC}%J~  !$Ƣ_o }˞ ]_DhTu@$J{{z QZv9D76ɞ7mBN~Ҿ]\_"rO'߷_9ʏb XWE > eDOI'?K?_E} P@!,Bٗ%ֱS*S+1 VH AR-HU:DۘB?+]>Ga:,}% b [yQ'ޔcj3(BvP'bѳ϶OM]O ׂ\y$j|vEB\?Y + ;^N'K?ܾ-}+n+{W"Sp]aEr#[*GK9h8a37[E[ c}e}70BBHضֿ;@_8$F!@b=r (8QP7\lп}ωlp*K??|\v X}cαuVK<lP,^|j|ɱ{ĺuM IDATSU$(}u!J,L¨~BFBȊW5otrSE_L ci/A B6S:пy?nEͶ-cɱ)UW٠w => 7/)< q"aqh6'c~B Ğ}X4Md?bV<<4{hKM@ ko,')1 O͋*,?ڷ^U>|6Hx nc^ `]e(\Kp)20Rl @FBHP'Y"?Gwy?EmwbN5NP7pJ`!M@ koLq 9/<r$}>f@EѰ.Ca/e(]' <_r{E} !O!:0o!Nt{۩)EǻV.TB?< ^ Qx#޵7O#rlR qP?Hfғ/=vig6z2=u=;}>a_s)7oA2ξ#7.8tQkKT?C.pW6G381?|5>g @\l: !dֈXϢg}ˌQH Oo г+@Ueeq&ZڗȦ(o3}ck n>jNe*\kQпAR_-~;*~ks ?*ӷe(dE?3B GNj) {g[y尒'l )9ȀBABF5RǺ]?1 Ԃ5@nH Q+ji|򡲗hnпz ;c>xklu*8`w/`UHxo`]q+.W V@!|T^) !,C!d7vhz} ЪO ӂ\ .<#~ Lth>-Ndh}/}r#JA q|l$@1BHFBH?V0oF"2#ȍ=(hQ࠾̯N[6WM0>Fvvm}@L%s''PqV Gu*n+r,w4`RbP򂻫"~toܒlBȾP' Y[o=$w?W}2Z;ֿOoтD 2\{xhyCX~o#kɻ q@B(m C誟mJX !~BW G7LQM7$# >dX/V4 >Y)3;jTO!DŽb?!D CvͲwc n*#UE"ȝ ӂ JgKd)MHMU)/|x9wP?0*~60u ooWڢJ?F.^?+6)D%wg?FBCB"d(,[)C??$gFP&@\ޣjЈBavjB )#ؕqzC8,\!}9uY/x㿬Ċ Jn/W|Wl`%@/O!b?!S m(?m7?R77~}_Ŀ 5#[P_b veP'A6SIh}>VXteq+s]R2812j\0[gT?! !$RD(gۑ}79Y\4+X(9 2e/S <{ScZUe*Geߤu\~}GPs?CoʔS`)`۽&g.Cr6ǣ N@'Pxѯz C~OgT?!3kDz)Ud8_~_46@D!P[(۷GDŽ+N#oAQшٷW"o[Zjs9HSϷ#eyYߪLWПV"QJJ^aJPCM]N!$N(BH,oG? mJOoNᯔ jhD(G4lVRw{x OQJ@}})O=0B ~BiBtIYo6l}  Y /xhFPW7пw9Y_߼ogk_Ծu]L}TNL6.= t.%FTMV";PS6?^7R8<>?W<\ҧp#sOмω*R!d?(Bx%o }׈CfQT8"(+x(apX$/c S(+܈~٩[fU_ZM%alX|? 9n#\Tɵ,sG#}'1[vƣ S tX>6l=⳼28I @B# c˟j?!O!0'uJ"_: 4DjChBNBM)B"~K(o-~;*~ks ?k*ӷe(E?3B 'X^~> !R%?ԟ'b?!55BX7?KJ/(I=i_pZ)(% xpJ _^Bz)/.EkŲA(!_m}07tT mq +Sw{w.Y/8EJ߿ 8[p '6Ga.U iEOޟ?&G!P'3'~,Fw)F+UKaEm_5,.+xgH!w$`c9˱a|9s,oR. KS̑f>mbyDe*17B &Yp>N? P+e(n}Ex ^JSfEe9 !P' F6 40J`t%{(ETzO (8nB` LQ C؟G|!̹п}x> qGċ Q {yVBf(%1Z}$u7,k{E_ f,M?5ҿm|LMߤ /!Pxma3S?jϳ}˴/?GVc_aFx>X. P{*(\§0Fw loO!$ !$Rt϶jSW imD{*h^&Mh B,Bl)PC dh礧%܊?,>Y2XIDw}`Y)c0}?!1sWۗοgb%5hkb9(/}cz|y%bإʾ#`@[ե6(ӨdUJ}JHQO羞D!$< !$v  j\ohGCA@CyB97 !틕}{ЏէOW8yc0˅S?k\W lzZEC b`}^(W7{EScR<? Ͳm''sABN܁S *Ͷ}) )tD R  z+(B?T>Gh}U)V)P| sG/ng(%$ s"~BrDcHnD{\ H JiܼJrDBH-x\_V4Blr;cnptE1Idx2 j߄m%2޾G9鿡Ϸ`e)jh ?)/ J{ŽW|nsXB)%ޏEw~=[3}ܶ9|B9& ! ο]_vCΜ}D=@CjAZ U`t6ܷ\Y(}]_Xck{_&,3шٷKDm+\k<ֿcK>8c#|S,9HV _ᑌS=K|nJ,YBP'Vt}ڿÑ]mѿ+xh%H4jA'U R 0B?'OrL#(o-~/R +IG_R"_ ?(B?yXQ^?\簒JCAF" 1}?!O!ctS+{YFnF*0^(A] WUIal^|6=п%(eJON\~~ȉ? df+(?֋Y.'C|~=|PJ_nx IDATh'TCmC BMo>?wE?|1P @N<Ӹ+W(1B. o%ޏeQ|Gh߻G xkGË O`}G/> 6e($ Pj8j5MӦ}j3}?! !䀬οOȟ ռ.FK{$J!H_Z f݄~I7k=XȂU"IU*;fqcome.iGnq)8־Ϝ6:)`8EͺjzGrCDt__JzY*1ѿn0Ծf~By_(BAYSZY:uL~C{Y[ܟ#S(AVH@ACs/([囟@___o/c9?X}cαuVK<lP,^|j|ɱ{]u}4N8W{O*೼V^qXB%?K͍-YBȾP'm-M>hhE`ÉjW|)Bn/A#BǦп7N)|)TI^ׯ.O]~٫(eWnea} (e1f0(zySo`~B!CP'Uq2OIQP~ eZ $ZxCx(Q*48NHq ; ہv ˏͰO._rXxq^kϲ?6Q+ {S.pRE#ݨ,nۙ !P'f:cs/?_"{Jyx88P9CPEUcV(뇡п'8d;!зIGh}qKA)P+>W Jꧤ,_#bFB@BdYQP)},lLGN(Ax /眢yʡғ/=vig6z2=u=(Pp1yH}DwDl>ArgY^QG5 ?K,HjnOܟ !P'7d]6οo]`\Uo$ dZ THq kx^K _Ag\8_6/eKUs,rQ,^u*;\>fζHW;DrE#K;HI̖hcT7VbyPuDG>uT RE&E~ r>(BY.3#kW3 ]~FyȌ 3D RP!^ÉzB^q.c#c c [f]3 [ B +_[пQ8`BgR*^~__7{e(%P+uߎ+w6P{{->8(B~B9Sftk <ߍV a>Gh_pGB48x+n_+r#K?$7v{OKۿM!P'7gnz>/PR/?A&%%0Zh RqsW{BN/}*B 's%-cP!-.So¶踛Dt.7bqV GY=Dl_{G8x(x1.Ga/x+?W\9l[ `\z?e@o0BN ~B9cf?ЂF7 Rj#HP'Hm(tw?-5h S"Z H4o%Px(4 vP~oNW;~{~п樅L 翡BϏ#>U(|^7B\Q'K׺NݟW܊?(}*@AKKgs_x-1B9O!'flg_CcL‹[',?9y+hx֧.Gr+N@Q %,d^~YV<ȩVgP5sYC<' /@ǝaf旟5?fntQwD8nQg^,7Vp/f]eٽ8TP P"Pax nRRx*-{ZnP'7,;XWo#P V#3@Fp U0JC+5xC˝ !~8' hEpPY1jI;+O{sI{.#Bե6(S o)JwAaB28Iaeja~Z sO!~By#G8+fygy#̋a+7 h?W ._yX_ zzr_5xB7.oa}R; O_}sE;8yc0˅S?k\W Gt9J{ţ^W6G)hk}Bܬx !=O!4!?,OooQ}̀Z)h7v&$J( U U36^W+nnw{EsX0槠?UBn#}Tj !mO!lX[{ۂ}sW"F쳲E 4[Z= QPz`/AL}$Y)XWoޚFu,̉oo3tyq3cZUe*GeߋDDTw45==7L9E M3o)Oa]e(]^9 S_IsDBKRB97 !M9'vT_fcI"~c$Vp '{4UoݫCg͂P"7:BC鬘p{ՎuOpmp/V"UI X^?8Gz(=w}g~-<T.Kck-+ " N/?p/Up9.G2.E3>JhmFh>Q+5B9 !Ln )7dQCTѿ寔R=RXdNPZ 7@nBbPU9_=X 7}w1 }d^aI;/(S_aͅ <?o SNߥDQ~ɨ| 2{QOQN8$@5"^,OSSڷLO!b?!1s#fvp:7"~3;PZfHGjJ#Hl?Qݤ3WGis?W|mgJVL+S' ! -5 C[~n^Ͳ1Xo^JJ wp PzZ Ҿ>oAB7Zt>,J?##PѾG xk藇r3/ O|S.Gr+>?,V^q9 UHa&S} B ~B!An+Ou}ߵ'(#~D|$)(PzU,BBDց1CI_w0^Bg){K= ufQZΌE~҅|=lq[q[KgEnq)8־Ϝ6:)`8EͺjzGr!Pe˧(]R.CrXC]ԃ]BUX-#] o@wSlnG7Q_mݷRм`l-k@kAbL-z(X(:yy|HN}};B?5eP6w>fgBևB}>a>2q%~]4 ^7pb}3.Ca/W+> uTg(|RIS_dO!$&(Bl)}{ߎF7˻[_ r#(@J`=`p]kdЏ~Y)ZWRKe*N=9"3b()=棱oinL3`K t.%FTMV";PS6?HXD|REΛ%pT)]ҧ(]V9.GR> 1O#"n,:NBHH(Bm -W Bn?51}AJhAbJwBR ; $?k ;m%xFRujȈuz<-\Xsss,cyɱ>eƜcH$ZT>ľ#Aql?6N%}cU(/eUľOa]ҥ(mo]R/Oa}'+V2P:h~`^gmZ Bb?! 5u JǢH x   _.Cr>^q/T._E;1Ey(h5-:LП">w{ywY{_B?!-O!dr!6۠y%ﱔ}1旰辴D $F` qр@FpR+ WpT)Y˥񭿛:~ie2rg{&BPH7c',?Gh~9lVH4GZ{?:!:UuKߩͯe!b4f_wQ/}R(az9JWykZ6{B! ~B!S28wߠ9iߗʿl@% c{@{-ZFWRdJ_)"D~W(zYQF=A=(CA v @uoN )}0pZes踛\T":\wD8\ݡ8oeN 7>U:zt t$WWQMTS-o~B~B c% IDAT!{C&rc|L6@թDN41˗=@U*xxQ_JT2rDS쿡DYb;_U2;bcdlXa|9s,os?8cmT8`G"%&g]Wǩi["_| l~qًkH{*]@RŴzM[]|(54>=֞O!dO(BM iDchg/;/-_ 6Q\ |%lȬ=]Uu3#m`[9a#BzL |+_rK?UzY9UI(LV YWysŠ:m/[z43f>B!Bĺ}ԏϫtNy~zY~*Bs_nK"~MOҬ/?IO)˫_- h^]|+Sӛfo[[F1F/l>c0ձCwSݷ=z3;y^+YCwe[PL[%@ϰ |K")qN/sV_Ƀ}FEeny@Y̞|__}*߳zyyKcDŽo+!R~b?f`>]hnH˿m]}.>sYR=ES"Y)ޗ3%򛲼Dۿ.+\19ZZԞ?M6 ܓpOtaQ_jU{Y)sK9;^_gKtJė!h˳mrϔ|c.7Πpl&yg= Yb38v<Q#c.azh~7[5q}]~E~\UB͗R\{[yП(BOs/シkЧ!cC)a0>Hh1u .Q|nJrKYRxk^T. Pyʥr===P=xcB1FdGg*[.xϢZS>,T5`Em0]m{MN9qur?䟂wk'v Tt-Vw '/" =-hqi"7y\{*Ŀ̗eh6lw>Pmq. ,`y Ne~o'[]N x 2ئҾs={Gn8Е^'WsϬPS+vR"dDOa7T/{?ă7>]} Ohӻ Ex=CEmc' 4|v^y$/<%*xګ=DVQ1Ps}~fGn˱,xhj..xȽjRߧ)?.Aʣ==Nʐu:{JJ/CNozcЭy zbڄ]XXNFI]oϷt9R4uEԞ?5_hU^н={OL(ugj׻w@hFc^tf!Ķ+Ow<1,vï7]Oܧ¿ g//#Q]񽜗Ƅ߼N`I1Vڿ7L!_+{eyDOk)vS"# p ?9ua1d?6L 7kB֫.^=^$Ƕ˿$q:yө_x'+ m"N߽+@C_4FJ?*mnWzzY1/q\]]w\ojm#LKbk}C!qŎ';>ZLİqBfPq{ݒczfZn?ZU?=믥X_yNkNxtjhn}v$眼gI}߶zWz/A9AIybKw]+i^~/2q@p_-&}LB~9zI\":~&ze{͓!Է?_} OOM\鹟{]5@ַwտI>`y؂ܤ4BNs󺥉y'{z砿1QajҬ|cM۫(c@[1SUr[gzʹq^]Vzk \j4{T Oa)7x7#L|;!Nu1k[>c070oy.Td/:ü}k7&׿k]߶סz\T@I k/>Nox9iF X"i[BL9|}bSՄϿHiNЯיIî燁I؇}9fJS:]oNWx'[XWK$^I]9uƷ{>/n+?[G[7 vf>]f{?0$ww?^q4[d~oɋ5C7qm"|?Ɔ< nl<`q~[9k.K")78ijH^V6ۏu0b" lSz(5_U2U\U|7Xnΐ{ŏ`bäխ¾9M0-a޾Y޾,/<)ͽyR( O4:Wb_y{RϫP_VEE)'5=~۱Z~^DXB }ma1lr>~es`Řo} BbUz<5xzѻ! 9v}Sk1eo~X~XӼ߷X/clj&T Կ O{ _._U _lgu{1(P_Ct3T!]蚰~kS6ElلX}s]ZNm8Z^ˠ2٤M:}|؞ Q˕*->J!*'Fy4S}.5&˿kܢ[}6y7wc>m{|*5W:EεSJJݛ_m?`Z~ o=&ܩ пy]\HG_2?s]y/Tz'I'/wUH"${_?S(?1Qe:OqeeG o=g_aNW+'%Swk_u !mc}. )x"!vB?b?ovd߮W&yx" .DuۮC$2T-Qk>;pC ?LN /WLuΗBqu%aڪ yvq_{!b>ۜO)7nۧg_tu ڿkc ]^}C{mS{}>{WM AO7PR 7"B0a~틝6IX$yoUss pg!B/}n]m-;*O(0w}XS` zxɰ]yuC} sy}} v;u `w߀P!}-dBΈ+B!mnߌimGn>h~D/quxp .;a'{|h6utxkk\/q=ߵsCEw}!6gȾs!y><  &Xim}6u }]v]wum>t=קwN}oa{ZZZ :bH6n_gA漡,s0+_(gսQNI9y/o?wBK_;5`ϵcN >dۻV,4V郅S=Na o su%ڿowC}g_݆wsmk%w]wyā)CuƬ8+?z5'μ3^ߦEz|DVN{9[M?˳s{[5ޣt9ڏ.D:KLhm-)+< 챂YD!߇CB׷?wu;}_~7˿926k,|~p,U_X-طm^]CڑG\ݽeJ)6qo;FykIHmc1B%ɓ:JA(:Kzڿ"wm ۹뻺-B&tICǚoH^G,o_n} m8v"s|.Zg|44bGHzmz6æ"Xuh)p%xUGO^Eo_kAZҾH]cم)ӕ^ս˹R_>箶00wKMyXpr;W? y?eY!B͋&uP^zKˊC%z{&O XI)tcﶅ\, kSXYച;-bmʹL2[-^G&u1(J/qB.Mrm%=ش{ayԲs)Qlc4B?b?DA^˿ڿo n_"*oۿomcY)ytc9LRWU[ :oϩ՚=N7B?%=B?B,2׳/&E(g0 >g!އ|t{RQƔߐ `H?q:ȿD`. /izhCE4uBԇ ]OЕC8s:3!Csl5 kJL5we (\ʹy\a܋G? ibk{`ٺ5SzGB]A-K9&L 0  LOi }8Ymo]!K u)^!ٖ:s3r}<&t\ʫ#b_}hߤw]JB;?t?|S""NYw|5ԇ >mCڱ9UGKğ*O`5CۇF0V/d"Ôtmص-/BAGS2]2vgܵޏjז,6ξ ePw2@BF6&rж g2?c1QMY|ljUvz9 QǦ'" b?C=_CoK׵OwthN賷8C}9D}w>6gGM!-T[ }jZ-blBrCՏ5ުE>?Fc-sxtS =`?1}v~ZSc 7`wB/TP)>CҡNxӐNZ1D #aug3zW¢ Xo؄?(!#P?ְ{$9'| mƴKnc5N^!i!C/4B't?$u҉r}Ȑrcwscd.ZЦg_Tm4A!;`5kz󇼆zy^:?lצ T1L1s].f {2 ;ױmߍl"wu~I]/?ޝc?qRS]6ZS!(ZaP8 (l&$f~sߵߐ}eG=g{[cp1Y`K\  ~XO^KovN;?sw!uccDVyOTƈ6X+;(ľlrtQyoiIf ]Z,ѵdKYjp}  w!:*7=~ss 1LSi ~8Lg;vѿ_u3877o@st C`9Go+C&z,vP^A!W~طX~ 3O^@,>{s{)7}ﻖv鋒v? l!ey>!;wϰK \WtKbd@O :s xNߥ־+M3w C IK\k,[j ]abC/v\BPZck?~綿5akBWߗ6hs%׷y7h"}~'p= $tkDZ3;#b??$Psih iƖw}rX' Bn0EPo7~޿ ڂ%hkwWR@ݺDkh)d}" ~s=?愀%]Q` |py Hw]ājPf0sb}ԛ>1( e}ioȉg ?yaH>C<Wmu} ]?gLhwBm@pGjS-}%# Lb?3>@ s ]?d@(sMz:{a 6Az!uT+yߙ,ywa_@ZZ¾ǻ#¿采s\}**}{roc# 1`X=۶O7t|X9轄M=g&33b?|L'>hIuA1 暨@wyX'_Z6 IDAT˧ڄm($Z$w-&캥(lgi׏ԞBQW~uϭvM({I(d)-K?gw>\v!CwC-G|Wjc%y9#AL:sc94;q?Tn9qLh8rߢu[maп i-u"`ϵ'uaz:5& #؇}- ?MYV!;~}IQگvru=JH9KLc d.wE+NNWt&:Mq[#CLXLSO b?_@c:COhvC1 &D.vS82P[B,[o.ЉCŸNZ_4"'4Ii.g+l/L\WKս_z Abz'rz.|Uy|6lo.3R}˶g1 K/_{7 ~3W'vB=ƣ4ϨZ_>m}I_cpFkL^ětNrL_g{&}ߟʋ_z ncְܽ1ݾ)K7M}.WVDӮFB;Ap}Oܣ7|#b v[l !9?E`Ddڮܾˁ LX}䝔$)ׯSnR8]N[&eI.)I$}R f^™ww|_)WqzJjkhFY^hU*a_IGgqsYZv_]ƿy+)죿x$ 8*b?R^5& =_]+X9?ywi_kZҾHݨچe<$mᝓDMr'srN:L}I׻W-wNRwYM<š<}M5q_RCோŷ :>1[r`OLp!Ţ#א:,?n㻧_=7{S59&bs^f2ILlSz. "o\|pd ~ǵ-֜]c"x B "F]#os`ziim2>-^G&SC1 w)1]߿LLb4J3/LrI&m h/[jZ}W^d~n/ZL?)qR5I_r2%{[%RfNO29މCP~lHO^|u2V @3t&C;[M.=59ږCCklF蟖r=WJ.g) 9|+5)reYӓDʈEq)8EXv8lp)N{'=kK4Ire~_B5^%^V6?Eyfz_t,'@yCGc}_.?lC}y~a^|2ٗtNMi攛)˝r+EWMD l_z;O"DV?~Z|vlkz7] ONKvR2Ii~II2/w_*~c DOB@XùP1rD.oP~~ⲯ?$;);9ey/e|rΔ'ܗ,,g9/5_a `. ~;ױNXāepޕq Ix-F M;L,m_Dr}{Tx'I9ILY^έh+n۵j?B[}܆oX6,'_vGE <5C-}/ <-K#"cBy a}Er{UMę\.+/2IK.L_r9!޷]?71O} V&r?s|!?YMr#{z-t\<yLWPf[]2d\s.^Sx\V~[i!UW.bB_߶\gǞ%6~b?@=zud)2?>D(0q}orA׹'vSA^lVo%?.R9UBͳߞu6zg ~#~x g\ r&lw4C8W,(bwb.r𬷏)ȟB%R__*fʳ,my=f=ߗޏjxNK'鴴x~VR[݋\J%{KE eoj{q۱JQ_-a^m}w3G8=f? 9 GZ ciZL*C<򏑧ҧ7A|]"&!.Ow,]5l˸~lL3Rey{kkS p B? b?up8RG`oU~ɪ_=OyןC<>-P>:B0~b+3Bxm*ئ{ᴛݾѵ܅}{qU},OY&8m)߿zTghj< G/ m,5A&pg͉/ô67r)ɗ^yk,ǾXc<]M; c{mZL2:nT*bEد$x7?]o!(ۃKpk=ϴ )Bj}T_Տᇴ/BBf럭ٟKΗ|_!b~>}lw@  (B`#kc`F?${E^8,Bi_L/;v3ׯ"<}; ~D@\ m,Ael g}q pCڅVN/bjԏk1f u䀭“OHkk7vFT?<~Afྱ}{[-/@>,_&= la7,79o4]{_?5~p?O 0aA|xk!,qhzX^$OcYc9xWyJ6rB Iicʹ72moE2|Cȯgպ 'Gٗh>AO017S#Bn78cʹWZZ{Y_2I%RO F`mL"2M,0`c?;?)uVoB7jCo?box?Os`]퇘O+,XZ[| Bx%"w~L:p7cxV1hdk,ovylj=Q,(OL2z~niD}*q>{f,`^3q`BJ?--B w+(ot/F>Qorw>1@ :%T ѯW;}[zZs}k_TF#\eX#?u"` Cդ#A0(S>#oR&B?TMYCAX#,awطBڨ?-l/,|iMfo7 ߿}X?\ǟ)7wCm};P5id̉,^DBo%&LA,fzvmE\? 8ų۷R9[*mE^{VQ@~<bA{M+EBmtb3BϽS?Lƥ_B?TY{ c3ہEn%;pmdk#ӟ,,}؇}؇}؇}&g@?gj1*"$;`͐<߃}i-k V?]8rݪ~F޾u5_#…l`"^A~' ޅs ΈnK˱aZ?FV#4֏yjϵZL2:nTl| =b}<~2120ZZii7ڨ~G? J]Zܾ!hB;3ms~Ҥ-Wj+6"8;kc..^X"/kȿ+0/u_ B?TMY{`@[7!i!$n@D<,B?TMY{`/M9meȴy' Er-;9l imj~l#z,|,}s*o-?)?j8'`GluYcyBX|rͻPd}7wrn;pfۛ}.s!OMпp9[f۰obBqǔs#f3B 5w~+P5ed g |Dmk T~{, m`,(؇}؇}d<_8P5gd~TơF"o񐴶e^n_@Z^FowՒ ԏ?u٬^A自9# ਜZc@2(>%P5id|'=df]vKݕ|..q0ݑ57mY$e-z|2h9[fۧM&p=3e|x&,O~7½ +^iiEKk3:"wB\!џ~UF' gS,wþGr=`=jvT0 ]h`ߧg({,OSz>;gg^vVHM5}Y&ط}cV>~.<U3Hs" ؛c1dyb$V?L(b߆`q[ e|-m6"3LUSFYe OK/-[Qs##>~(A(ABove w_M]cB?TYzD ͇aaa>a 2A-'I`_@Z輻-Y!waߧՏ#߭Aon)Kh2x {&, b?| d0fuldOY;DM; c{: IDATaq-˧7&_/gs+| C B?@a|?(-BՏᇴ/B?B%=x{Ik@cߖ?Č;}Fe|!_` C Xo@kډY}G {f,xb?;ߔ,6:dFyaWH;ɫ?vV?Z^"oLzݾ%| C B?@ 'f9' Dzr>[#|r6nbIicʹ7{qW=~8P0~.E_d3^" {qoZii C B?@f lݶ?p#" /^OCmvb?DO!iC>þwAF$G>cZ6v6٦wѵlKkc8jݪ~ld>&@l CD9q&A>ik,QZz58Hkԏ=m+h6oDӾB? L>IpS jY눭`B%3@Xӆg\coϨk"/hp`&Cָ]س`Jm C B? E 輡imnߌimnǧ-fo#mdœ/3 n`fަjĘ"y莬Wj=EJ6[d8dʹw 0'Ρ_USIa`?QuMWZZB>gr2s;} )Y xVxcQw$Oa<5s+7/˽lLrΊ?LΊ) C B?  ^mh&]UT{_meP[orR_? N/rG)|-=bj@sK B?<`I~{XfF H}XH5ľ lSZ}{W'I͊1!7KWX/UR7MJ|&\V?JVGSyl[[m#S~q܋gorj+)Q?rJDY螝f'Ii~=KYʉV(#2;)C/D}_zS 1d:%NIs܋T䮳L#=?q^`q>L.@f~п?(-BDu$? 1tOϺgғnEs[Z%Jӫ_{PfNe("ry:S]ΙNw]w]N_fw}s IvPasJGVlVAu^.ߖQ/?34/W-ʕ\*ܼsrWJ|K4(&?:үU.uK:lB?T^d@< <wF:b46&M$ǡ)cߘ]iڗ']_?/"\5=떝uOJr%>sm__o }B/甚2,OtKOs|馿/WE \Zx!6*~ۜ<(??--B,'g}~~_K޾kz=O+H*^apm<7\EX%YĥJ~}+͋e S Jr% r L&UFb?dO} ‘/3s]_s׿/{蚝b^D6_Rk8}k!;*;y幗Iyg}%'e彔$NI."Ϯ|ħԓ@w&,~&Elh؇}7оur/~koOz-?+IDB]y˝ҿ\$DYuΉS3Bx^A自)# >% I}lSλBuZe~nU?~n+MOg/}.sU2|tzMqK /|_ drgnYK,󲋗N]O]^m(gG&,~0̘ܺvvp5cSM usym[Տm2R"l׿/=ӳnY$^m!߅'W+BDPS MId&3Dy䊧HJR%.l0! B?TMY? J?}o+طGHk׏,?zG.ȟ_tO,)O}ƿI%w7F诋u>t˥{}?=Qfo09K%HNr&+ PoٚLA臾&!?3.}>~dy{zK߿o_gb‚enE_ -;+IWROWo }^U_?u-D?KzM2IY.L'_Nj³߻\ٟaB?,0&%}3}#ږ_nimi7S~L.[.gJ*M߹գ]!B0M M!?D鏹fY$Ikvѿ\rR{ey_g>әpS۵|n& C@s?W]mWM[cyug FzWr漡")kыё7f+gSmw$O3{g3{z֟//_}wz=/y˿~( _kOoF z _fY&nR{Di)Χ$ CvE> ~x4*_BF..?}&B? )BtQԏJ?UUx{H=wyW e4u^+7:uJRy˒T999XyRv>~.`?ԅ~ IݞQg?.pm,-K^߅w{ͫ%OQ?D쯞mYzMq74}y[lyc/adGK߷³߷<k[F/F?/ҟۗ]nII[Cw ]B!~ms{E3ݣͫP_\m̔'2L:Lr-]f'O7ڈp](g{&,!Bիݨ_}7>#Wt_ZJSg6NPkB@~u^Ryש! Bq[1MH%n_[C¾Hep[)l;CSX Za //{?/ݲr%b~w&Ww_zPU{7CW0MfݳDEntJRI)+i)3f'BW!` !p"b/!{Qhg^kO9DAl| Mk]]-bmƬw̗_{\YY g:%.]J(>LVeǦ&B\h`%~{ͺ,6dy{z-;^tKO']r%g'ÿG>@*_jӻ믙yKR.2T4 '>xh_')"=, B?7n3s b?K Dn5y&Y3>#o}]iv-=z^tND%oZaa}C"~}'eB/ħ.qfl7\mh!I0Pk`'-Kts1zKLYXu6v6٦wѵlKk]]طqUXar;^nI%|@"wS]տڷ)'7o+7{vw.טּڎ2UznȳDr B?2 Kb?<ZgDƮ=H+78eOmElb)k6G IDATVc@z[GS?~t@3_ 'ғw4]ܯ %=D>w m^;ճߊRO|&2y#y|L3搲96*@~h4dj*7=ՆPmڷk*UM:4Bap1a)7WYR'ݲD,Qn|gT?dmzs”献s 5tØz2+\_߹CWr^~9}덫L9㡀HCe1pg<$LiwS֟t@cV]ѿo@w}9e#_m< H>YX[ QcqZp̀B߂B?i!B?@B!Ȼ;>7r;b :QB^X祥 /,S zQ_EGqɢLU׮¿=jǶӷΜD9}%]>gzd(c3j\ճlY;ܼu B(BVxP'B!kηj`fw]yQ~1zln!G9mTo(8@AuLʡviHAĂ]}>bSs"s!dI BȚyP'BﵝmDi(쑍ϟSEW"Ut){VK7׉`,@uy]Ak?<8$qP̓|x3}f%-(΃ BxP'B!k)=P ڼE`l`-zMǰʥ7<X﹦wkC;~âs_:&~ _b?'X*]*ngjTtEAR(߯:[6v98=V?! PB6+~<B!w@zvj~޶Lkhvs(ϫ]ɹ6;+8O:O -_sa\Im-bﶳKeswCiƖ]۟ӿZg(ͺMv$˪$tEdi_u)(:7Go8(F^-'B!|"?7SYЯпAeu b~̛o~N4T;>emUXsO ~oZ(%?i_0՚5㜽&)(˃ BfXP'B!s8vY~+?lZ =&J׋yk}z>wsZkgi>*ߣ17smT( $1"I\dR/?_hi9fN~9!P'ߍ!d+E(B!5.x޷-}PheV? ¯4ox{<<1(<?Z践]OfKofGVcBȶO.1!aMB?@B!Nv"ouUcc3ldO__[o"+9Ͽ'ٶ~!v[9Mwm&~#ͱ>﹭{Gn\yr3 A#Eۂ5_y9)B@ BXB!tWj t"l:}7KiǬVwsvl[-͝Us߲E[}QM/Mc9Ue+~bq˻~jZ~cbPT%ίB,tq; @sX4S/~ftK߹{ρ B6~B!d-wW<\_{fU|G7Qpb O+i)9[>PB?uǣpHmm,+_9%OA4Jn"JgT[[AL9J-p,!䭡O.'GA/˟\O/d;c : ru&aZTSZ$qH@ӌs}jGEB [FB!W. \E7Qb*=PIEB R);ﺕߟm=je*+ K MI2$qPX?9̺@{@t!+gKB?@B!,;CDZ}KQ.3o^32 eI( ?A: `NCc,fNWOX~3 >4B?t!akB?@B!uʹLY͟`"$2G]CB,?c oD;FLW_=V4 yFC>0Ѓ+4#G:K0UO{A|![aB?@B!(?SBB-Bm)?L(~kr0%:㽦D_:bh;U5RGWRI:MdZNM'շ|W !B?i5߄uO!BXΙ+E_[O~_bV?39y|^`kVMdcMmX:R+y妰4 mY/nk<6B?ܩ!d]P'%E@vxO!B^}'<@u2Pd1PEv)2d#`w7Lُ&\zL+v[mގTǚr¬fZ/QWIw)P'FE(B!w*tMЃXc `LQi4_G p}~מ++嫢VR?aqm~"[ӊ[{ҁs֛,1A=4 ?U:*IauB~B6; yH !BscZM!{e_ז "V==elqr b>cW͑f|,8+}j+)?'Y5MԷҊWkTo~kn{<~JNs0p{[;7PY}$~غd KEQ'dP'!d#0B!<ƈl$)9T}1ғ ?/-~PaR|,ZWsE"5߯Tϵ`jTCQqukF{O_lE-:~ 9P'!d3P'B!2oCj,D XDq+9$?lN0?}C?וl[x{l &8#oqyYtݷtsҌ}_\H?W;bg_.]4NUDW 7}%}{MiB4f!]~b?!Biߩ(hX_뇽bCts Ɠϟ?u䷭#wk+AآV}t=e_?Tꭳt;C۞Z>gœUT K6ٰ1qMmGHA"m{~߾i Cmvn,kooy~9hǶwW8SE.!watt 2R )CH؟Ԣ.qJIs)g](B!{.0&w'+Ǿ !Dgm2H-H~&/ZmeJε{ѱJ>ߓS}9~u-w 1c=kp&vMt_.6[/D?7]hTtT~wSe>P lB#r"=hV!e(Sx?@5]& lAhO!By<2[T]I-8QF=br1=WkBBPZ +#_9oF81s ]/CgCBs?AS)#ZK]uXFO QWģmzVb_rQ},OvOK7W~b?!By񌇝C=xnƿqD{ABu-|hpO߸^zۚl-7f$qHn#_+w1y&#߆/cpmkD]_.͹dO S">E~B ~\Yl, !BȫgD=o6tth%GRrHb{y]MΑ,]S( ~@Lw yK؟#G m1aH7lޏ @з9BXcˮKC#\ mH/$/uTP۟l/:Z_gza(KWP'B!}w ws"@@P[mkNoceA K-ۯP]eD&`0HS2(c2ՔM۾mn}jBr(2" d; kW IDATP ~B!;3znNTC"ۿSY6FWC$)YXk` / eI(kp jQ+g"|Vt;%}_E6Q}bo]΀f;T`Q g%vY췱:dX[xT eOjFSxr=Ise(CB!<zrU`!-Rc3@[ Ic?F`l% g}D"rQρpƘ&HonTd '\kв94?7H8Mع}V#?㐗( 'A Ud98!eHp}á(C͹]BkO1@B!w3,{5x#'2f8 "󀳀Z{z~`/*iuErbx{1o(qGJ,}•(|@I~-wZcVX C߻]+w  >v'32a-cl Rs(p_ IG}9`BkOK2@B!duK*1+m9:29^^+![_4~,HO Tq&x"+JDVbWC1ZAݞ/g{t#s"B֎|ʿ*E bDwSsqllבvP.k{s;">!B?i.,ByP'B!ϻ[g?%XCgIpEDg<ЕO9yԕOܭ Fd.%y( b!b_o}w;OoosL_:T_bȺ>&wOE UM/O'32W۴lj? e-S8@d@6\Q">!χB?i.,BP'B!D/>Ys]{w65 $d>"e)dw&!oT]~F3˔68:1"S=g(c޻fy?yH hۀ•]Vbȳ]5^" %C e^A2$8;@uA}:(" d;P_~B!2~G<# l[t 'ω? & &Y@{ø[?-WwzL+9OO@Z]q[{X?^ TQYX:ޢLDH?$ ]|?o]۟lWWX$d6bW;./y-;)O$C Q;㐝(3+W`qC#Jl%cUaysx(" d;PB!dw#V}HD/ьG_*!}nz ˒R: Ͻc»*B5 QsQp&!OONۖ(!.rPt1 (\ޗϕ}/g;ڸ4)߳J> k.׎}'< b?!BC~m+?ȵHPqH0H(c XwL-7x]?8#&1@S>+%NCԂ#wE6mk߶o }|_Doϱo/FE)s}V_~!?P|wb6uHSv)G8$p,iŸ(2" d;P B!wVK C(NLrS^t,꣏TƒZ@Z}uc5X.b ja.! AdYXP{)+^omk#WCDf W>v(r_CDǩ(cxؿ޿W~'< ~B!ڻ;~`~D` pQFsrQ_b?wIZ п:B{B'k# woet*=w?U cbjˆDy,wYC~Gq?40hf(SS8ʔA k?'¿|?!B?i.,BBB!|`o[(hύW jSe*Cw1 kv| z8 o\~tXsmM;*I Uf*Uf {ޮTo8%vN K"~kC~!;ᐟOeG#l3L"%C Cc(pWSס< ܲ~B!2AVs;f}HoPXMP[?e1Ԃ0Fa*G2AcPIu)۟}VC+H}[U)ֲgr%8'/)/@A$CL9ʔ 産 IXXcg 0~:C4]!ہBcO!Bw>3-ҷ;%4\a%)&TB?p6!g U'`s>DtVTO;֞Ҿl7CT?ߺGT"ئ]5HC(20Ȝૌ8JX !o&7˺M{?ȳ3?Z*_2T,9uQC.sλ9B4`!ہBcO!BQh?ޘ?I!koCE %@Da`G*ь8ו- cNIeJRظc}D 19_wQok"1Ǣ)U@du!??#o ?ûg$gg} RmS2(%9e8A3Xkጅ1ι߯BnB?0B6CB!}Ftw0@! 箍sf"1CRrH»Emrt]yVM5参0rY!&xFNɢetbP G ]R}/B>%w#l"mѿK>>#'2WR}7@H;_xX[_s{ChZ2 ~\ Yl ρb?!BY~w}ctXo:]ɣ ƀsW q6UO-j1Z!jCH"N1"H1?>+)s}8D6cB?4"!s ]Εg%vGv_GىB*D(x)qB/䟊os97 !OK-@yP'B!뼋v׿6"G x2gw^e7E=OH{w[-ھ~荛Zɹ+n&7߽6XWU=Q  (`Ccqʔ!h@>οsDkm" Dd6V"E}~>?㐟p(Pd'8(/[$p{NaR25տDE~\.Yl υb?!By͝4}w?/~W8$ur&d>;L75_oٷ>!OJ/(%iuw&_oKm,)&>Uy" BGM}Bdp1?wȿ%ܲ?aPdgDK8CW͗EL9a|8֑J;ƬR{ kP'%E@v|(B!o_{G[k!"- U!`!P1Z=woKK_\]s( lwEVb;\1$@J mQmaLX;_⣎WCUXaQKu))UX j 8}9"I ,B?i.,B@B!}Fҁs,SR,d(c198[>`X.K@y?NWڬQx}@J6AHRs)!G/gn;޴SQS2+w7g.t ' c' $Rs+pJɑࡰtZO :?KXwB?i.,BAB!\}!+r"̍+1yBc K %u'P?ch,0H&A,Tove9e(GBZvvҨ>APt4=:FjJ}~ܗo$!(X bsʑAk/-vkLB~\^Yl b?!BY@߮o"bj A, e"D IW/BoXe%n6}Vlm~޷~pD@gT#rZJsQ !y!BEצ-D!lJ78*G[Fd."_/K3vEV'xWZ\]"GH;q2(cs- p0+ A%o>Ҩ~ d>Isd(B!9w#P-CLEwPqH Hj^bs3 b6M--A>7-[ϑ~.HO8WS?pꋮ @^/q(21GJrcSȪ#"lE7z D6pg}V"% rq%ǻGk~GLʴG~QJZ跃Bѡso|D~\Xl b?!BN|E1[}ʿof$ A T.8*j1&k&i*EN@Z +߿k 'R#8PXegP "$$nvu{P{? }/gwU8ᐟPdg䮬}L1KjZ$#x)qʘ#H0SV}׳ !?O.] Bfп(B!?F7޼(v?p9qQQdDG?#`}cmU$ )C 1TQ.VI0H ,28r0.E@!BVqwn4X;_ʘ,@B  ky` p}~̴cՕMpobxE(ߊkVD~+1CYO!C$IE^8Z PtWi&v_:U>껭a! gE—(?#s%M̷c8_cءL?G " yIsc(B!>w/$}P;ߝ+?%l=qBsmj5cIu[W[IR,A~ k<*ݙJ/3RK^I,x䪗8.֢DK\qE~kZGa^i%xݻgS8]~2'N-k2p,+w(@ jZ6;2B|_ ![BzO!BsEt]v+J 91*K($L)]B(~P$]V^ r4BE]i 1y1CHBD֗:?5¿z @%F췸m_ $d>k@RmV=;C 1(ǰgy{Sdr8cz3C|_ ![BO!Bw.A}b{oCT Zg,b_K2 pQzȻL yW,D IDAT™4\jjCn#ePF|=B"Ou+M\@2_"%|OA5i$CH;qsw :?s]OOsOK%@P'B!2oc¾@R* $,O1)8vY gyEen˟?o#=m˜ Zg}Qoϙxj7S4\/,b?j ZV/Vb+xxpZwM5iS8>p ;CR2?D!ο͉o;-}\B2Is9d(oB!wOnOz:f߶DBMqţLN!WY") X(<XDZ\IX?XXȍ'cgwH!y}}E~5P0DW jڎ6Mp6pgrsʠ>۔x'OuP'ͥE@vп-(B!?0l ;rl(y{m:$8 HbpV]uS!S_BB?}lcU6%Wtcz lzFx|oP /"I2p,w1q;ɡpOw~; /wN ~B!JDuqweP_d>"7.W^&y%Wz'CLʰ1yX hA5fԺ}'d9Isd(oB!1wwc?ο1; U!9* JC%[\-pB; !2 Q$BT~8=NqsQJ7-7}>Q/dT?!P'ͥE@vп](B!MDcvm*_UWV%d.c p;G U6CVC )UBUx+gIP-]t /B?!E$<| {|{b0GTTso7^PqI?v(" d;P(B!_!OmcHZοE@H6 N1J6!bGV 0r!7(ʸ)Yy>+q;#k-s#/-POH~rB6b?!BY K-2dD7"UI=Bq kZY (M0fB5!ς6?Bu9p ǰW SXGω_"ts' rK  B!w[gοoFok޻CU & @*?Xn~jlEW٪ϳks|v2};τG #*XWr1pQʾߺSQC[ O/,Bb?!ByLlQs4B7ڿ_($bjh@ d Xn(w['˒smvVT?O!>$Ps8+|W88e#kohe(E(' !B[p/;v綵?C*D YT c O)I)ߜBAEۺXY }HX 7C>۽G P'eE@vP'B!{uhmkcpUT)r]e zwX.K@y?Bʉ_ iRrD&'E_cAS$dKP'ͥE@vP'B!vcQwEf^Kk td@ p Ud? teP1_!亪_EU ǰ9$CR??N͉o6l B!̍|o34`(_pWu(Sc"& {qVW p.βސH%[E&c[{V?8"0@w8|,?Y8ΩW㮎oޛ~W{nS}UB~\Xl B!ο}~oT2((f8F E-B1P~5 GwΤY4{9oٷ>!k#]^p~<UplT,Q9+YOύocin5(" d;P{P'B!go}?nTwݮߎo"1@kyJb$) |-Wh?SQ@ `ºE_""Ge9刚b?4`Ԁ>]!kB?i.a,B ~B!2ۜ!;H.mk'7ў"Q(NZS"@"S g;w:PҕMpobxE(Dbkw*?Y~ X j5;Oor}n-#/C4G!ہB߅b?!By)nRzHCck-Փo{/Dp@(1Y~< p&l֢󷲹g2XK :s->>=NOCaZBezMO[yl%]!kB?i._,B ~B!aww# F7W2dRfBBGu{Ay!?|*+/jƅh~;!Y7Cs"aO֠O@P'B!/vSεoҴi VR1Z( Q8[+aG>ew!ʸ9m=>ǰ1(%D{3- c_tPľ^b>-A\.@ !BJx߾Qnt?m!;~wJ{ (ȬY5@cF8+0?]K[}1[!@B#8=NqRrDG~S~]a?f?oyYIsb(B!ds,+ܼwڃAE7Df8*I X$"&s?% 7 `羾5U(" d;P'm(B!(;ߖ Ͳm<?ۈeD 8S.!z3v.WXῦ9goyII=]_T.s+5j:8>Q%̻e&HU},Rތkqx33[2?m+!臣D?1Uϝ0'_/X"U1YhULF)YWf&ֿABa&VÇ /)?ipRN)T{)n>ѿTO}DQ@Csb@ο[./SJ_JQ~i+I>J&T-1RQ$U2l5AƤO[~IQJV)Y)Kw)E<Cmuݲn$w!8~dN¿o)K=Ƅ#?E՞?}:^O$>l_N?]t T|'VZN/:n8UobߴcF !I2R/['+*I}D[cdyX7u)׷ֳ+ACsb27M<"yJ}I6z?JRHRF4(F[2Gq9{Яlݿ*17K4O/7Wx (RҿwKMK8|ʕX7tciRK? 9Y` e1S@ĀC۾ajjO)}okj?-j/ET~*BI.p'U!uCHuJ?_?}_U$8B͟L&+k$Yp%wͭ!8~ vZﵿ'%FƘ/?9[1*L>Z`UT(#k2\5Q)O#}??`;zJ2J\ׇ?_KS_S5k?'-D>uˇM{-+@CsbݳK?O) oݷ~+3(FX-2$#)FSV(s `mc= &mp~ n܏)L!f 1W*~o.u g]bo{Ho?*֭ gܮ臹 RI 1ߔUF' ɨG)YSONIs^蔕]<+R^iՏUiWLv6.ݾEǝ-}[b&O*Y?T7}oS~_|ιQ'*>!a ]1K@K4^KS7i(Y>G{TDT4?[~[~Cz;]etr$c@tO;=7`\M;&ߎ>JO8? )YU?QGK_u$>jN*cd2OD3gDCpd?껩v_C)11*E(2`QI'W餿}R}dm3sV!/Gb&*›.]o_śxOq2;KQ}ӞIgpWD?<ū#¿]¿-Q=Qb47FI2 6ɚF5R?Bp+[2t_)\ۿL#n+$i[gVD?< +P4]_S$ֿn7m/o2QSFe.ʚ$B grWʹ klM8?OVIF)YŘǼJu(x?MpO5rF1ߍGl[Cp~8$Gmz;ڿk}]/I>*BBts]\P:erW(sFΆJ/HQ^tܩ}q~;y/`FA'pR*YE<ÿկo*I^'?GE{D?\0 a-#xoKf-Cҿ.yʘKN,__t>]9"k~οqg_Ic^4.^/k ^IJF!*>_Mև?*IpR*b^Gg2>?WߓDwm!8~Xd?9Kڷ?mߍoteF!oUxdK\oEy"37+ckT#"@So/}Բ'!erqGCp6~84K} h8cQJ*ӥTLeSBtJ2V&ɚ$yY$#A~#j/>%H(&XKK\ֿ>ʳ>IeU֢*kOI!o>w6D?lH:>/i4\)}IFE׍1 ѨFF%2Rs1)J9l5U1ITmZddSL+:Ŕɇp֟~_ҿ_ǿeʕ>?<4'uD?6 /78 ~ d?| ^)& 1 {svX[ϩ)KIID䭒R!ӥ<_t R+Rr.(^֦O~ozO,n=+pRr1WY.ӟM~U*?L&kH#6D?l  DsWw~5$W=R2UjO:Ys~9/tJI_%9vzN~Y7|+ɇJM*?ʳ."**}dl&S7S%_. IDATt{7W臭Abk.CovZf}&?rQF)Ze*Boɖz :)$ Y!s{;"~1f¿]w.ןM2ʪ:ےO׿$e3Д%>~hnQ q@ó@cNtk{ޱA^ocQ_'TK2Fʬ ɚ*V!:/})g2JoLv.#J9?EJV)V֜\O*>Qwo2YY7~jaܟ"և˱G![CpL-yyi7ۦ-vt#>MYM<ۂ}No$!ɖdSs}'NY<+RyV*^r6 g7M79-[oz{ =(&ripVOU և?Ϻ.2M.wG𯑲Jvݽ^Yw}lI%m_plmyZ!MhJ/'#TL;+wN)s^-t*IF2)ə$M[ Ӛ@Ծi>8?fjB\v}ist}]I?N>IpVOe?N4h彲z9gsACs;c~{MYC؟{DKQcNo $ )]tr^+uJl BS^MȔd\Q뤰߻G?izѸFOQO?RURː')W21|̔L&cєS#׊ooOYN]>Ly^<'~Wyn'EEKߕ6"?QHVed˚y\sV-prW'/_.D_/=X|ތ1gMK/)|GyRNw )SHNQőiۻS~;b m{S%~6==ɳ|(_ҬcL Dw#&LK+NHIV^k-cj!#]*E#缜 Ͻ(u_0÷kǓ|8oJ]7g]B2t}~藴(h6Cc3KCp7cكooOI?(6S))F"J" |trW(Je cbLղ~}K.?Ĵi.28ɇ\e8Qo?]Y"]#mп$e~OߋG<CpGJKM?WwK?';5ӷj>?('+u$SrʳNT]ye+s2l%3dmSJb`wVMosGI !S|!f+\e8 P/IpVN )SR&3To?u{z3Ԕ笹{{!8~+~,E vOM/ݢsy&$)H*S(+џr9z¿)+tΫ)+SUn\UIΦ59{{wǓJO*Q'1Wr!O2Z)SNV٬}#bJgDmF-ACs{b @>swEܴs& f&T b* Ly3QeTNɕz .z?]RF$$9Q"8<1fſRÿ|ӟ?幎SBܧ12ޕS"l?]ޓ~ =1{P޾ֿ(o}qRR^Aɕ٠:BeSJ~{dYߓҿ%j9As~G8Y K]7.Wy%LQVI Oڷ^DkGm =K-y~hnO q@@x𗦧*k=}ꚉer1(*Jǭ侏y2IyV*^edLQT{2&ݙ;_pj^MFIF)۫TU#Cg].Y?>I\i@)n)NGl Cppݺu}QW_Do }m!hU*qLY,R\%lQzs2Z@?'|3^EUN1e )\!f 1Y_ |*MR7)/")WT&Y'P?"9ҿ\]Ww|n5l [Cpp$=l%MM/W7Knk,(ҿicdeT&|t\UD~r;.*Ye8 Je^c2fd'WMHur.:Imwomw/I?V>};G0R/iv_v1Жs7ےQHV!:!5QFADYYSEgYsVW˷ 1SLNIF2o2(Nֆ7I׭EH;az4br+6O*IEGVKg.DLIN.J??Tw{>[D?d?c6sr}njHn}ƿ]>&tcTLmeSj"-nB'Y Ny96VM-׉ͤzXUeI,Lc)SN! 1R VN(Ϻ.?p%TLCܟwecI49m%~hnQ q@ÑAL_,Dwm?te|y Iv9?.(ȨF&T? |)+;̖\3^ r6/k&gD?!zq]=XJiC̪DVB#|=̇\eU\eT\xOdK{men]_œhvcm=_,}6~hnQ q@ALd(Z-oڿ|ƢΡ(zJO? (2Vc%lPf2땹ZKw[fKe+R&IZdoQQ؂bcé!My^,N !U|&E9iYj[{X3Nl [Cp@d/i(njXjTEOmӝ0ddTEwzEz @Q٠:R딕:eNYʳJ -ɘ(gKUTJߤ?'>WQ/C_Led~NQVIV2Vd1FVͿ4bo?I?[7T?Vh5l [Cp]@,_-K?,]׷)CeK__d飠dP|bQEwL)w>'̕˚$$pJNϟO*I?*YROg]B^m:M5UC2^9_%MyY7ܢ`!Is{s2dT&]RQN1e)SH|T N])k&Ș$cI2e:U@5s~ )%$[-UQJFJ'NR:?Y3$# e]]^++~hnS q@wKc_Zf(wҿ9W%N*ȨLF)T!U +^ mze+^2 g&4Vi +O[;V^t\mz޿Pɇ\>f+d*c}M*}SjsW?*{~>~~a@w s">ֿ[75ʿ[>ڿ-ʇߴ{$_B !A r$kZF蛠̅ uW֓euI2q╰hO}Sn7UԾJZ'*߈Оzl59 䗱WѿuV^Yw}Ժ% %~hnS q@wL߭"ko$Jv%h|u~_R;J쟜9+ur):eES!Z(p]E\D?QH*J2UpR[)}WES+ee~rO*JuYUU_C?K{n ` M1d?<+oC$XݔhI{9JkJBs(Ҙ)R!S:YdRhI{+D|cLb߇pR*ҥ~EULeotMY_vQgK{u}mf_ACsb;~yTKF24e5$}_ݽ)Sif_ACsb~~ #o?=`,;+Ǣ+[;3@QUw+*4w'徊ϭW2[*A~Ydm5A^Zdl5`mUU&i&.^tUϏ?V1MbդNN16QvUfPKT\eʘɧ `r3Eک-uקO{ͭ!8~) 6QCmZ; OM۾'}v~JV2F!Y%ŐLA9 Mg6(^ r_'d9_˝WJU?9!u)@}_4!=*?R1BȮއM)W XoOPkb@U]=i )'k[I>{BRߓCm=v[VD?$MِoYK_79ν S%X]wCv()*)($-%eM5mr2s,eTJEk$9g\5]+U"?Ie<ɇN~ec2j2@c-դ2J/{-KJoen%BY^]ԺGn`kpÀ臟 jGRƤ~{{N=yoBc~:ujorWsv:eZ箔wNPpB .SJ9嬗AfT<(m?[IH*%we+_zOM䛈$kd3P]|n>kQ/ivAç/A8~ ȳǤ7½ݶao nYWk3VY;)r唼WHA>zycޒA+U2WVze2&UߨT$ES^3 \Uoo q{"eJFedmu~ND&?S7)?:%YkVHoM mwWגSN}xY` e1?d?YCKZ-{_}"#ǎyOE ^ߔcT2B*UQ!9^Y8B?VK g2W- r6X*sATeU]{^*Ț 29}[3Slj:z?&j>_˺G'2dd?E?}o}[7JRF<D?4,8 'xoFw_cJR}߭N$*3.)V,E[2>^_&A"fk28,yJ6sI&Փpok ߖwij&@ Ф~lJ=Z{]_[g__nXwᔺCe{k`kpÀ臟"MR??6>S3t'k/KR2)*lکV5IFAV`2;uyWnUYFTx٠:R'u%)?ZX(Otl; IDAT˔L`voG!5Q1~2j /kEc^/BrJI_, {@9{^KSߋjצpI  򟻏)Jn)eCϐݼ=q+LAB*RPr*}_*wgeTޤ΄7$`q-12\T}MًA7}*32ʬR2cJkyc;F_5T\>ګoc2^ҤKڍW;e\ܺk-LhCe++mS&VSTP|Blu&ʚ kUZ[[o^(Ӭ+}~ϖ^4?3`Ud'-c_vyUjbJBtVuN!Uf=$'2EY9g&Eϯ-SʺuCmu wɿ~_pA쐵%.h49ߓS r1ʧ s:տ rg&?Q`M}ݷu_k}^MMr#DIRk9߈Yn헿LMޢfXk}XmJ/Y)j3^% k:;hT}2zߴ֫0h85?Eo?Gߋҟ?~͜lgD?@?~()}H|h1`J}⿽tbtJ))$}YdR5QfrW'ZK?^-\.Ke˚*YS,f$:~Lm_jߔU*˘ Net Jy;SOYS%#؟>e{j9kr?-!8~a`(YCw eh/L0꿯nJv>8BQAB)A5?șQz_M E:ݿ16Qܲ T1ղ.כLuLWU6 fcdtzOFWJ5R{ZW_*%SO_#-?5_KnV_Kωk7Thylh}TLi?;}g#D?8~Fiҿ˜h)M*hvWWV1%EdcPHA>R[>*ze'^-ӧģt`IS\D?T? EEc%Ec--:ߪ%uZ2?]'4ҿYw6&jҀm551ھM0R-A:Tr"ꍠ*F{nk1\߈hrR2X{>e$ceF"⭵%my]4lJ֯x~hnc q@LpP֊o%m+5SՍɈgf95X=n$Y%%E%T׏M([:Y7~+3L \ls AIYl3@6;W7XF؇)Z?^#3xV2|[)륺ۦ~.O2522rG꧶~k Co| HltS͜}ܓc~Ǟ+̙0O| I1fwu~%enʜ`u43Aޮ&xY>E7)iOFz}I^lR^ZWmq?$č1Rh)Q?T6vlyoc}ʦ߫R?>s|.8*~hng q@ 8k3%~m`J$yԉ}mTWn*J 15Q>Ee)(KQ!RPn\~ L_]ҿiTղ-d:\ͭ)ӭmmZ VO94xͧQjiZ4Ut]c\w;vSo'[G[Ŏ܏MD5~V?::S)ZbZhk@KEʖ_vj>-*U6k{ y2fj["5L {c1w}Qs[?n98%%cK$4.TKk\#F[SDQTUU+";J]-o7ړ>=ઐMRK&%%[&=}Lmўn> 7dZTK[Mh6QMdW-QM]Mf[yVuVANXYkfG/cus=uKˇ;~J{ߍK~8:~ { d?7c(#2kH}cCusn9^AI!E%9E%PK*a&:?*zK,[Z&|Y/ݣ)\mxYT_znӖu ߊڏd*1)VWYZNƞOedr}M߭wjCeSk|;d?7emtS' MߔM*?%X)ݲ˵~$9ٓh*zIZw6NR>nN#EoOWD? ~GvcYƤ~_%?З[>t>S~cm[.4V?g_AOI6UimKI>"?k;S#msoNTE#~Lk?//eK>}}$S7-ߎo$}]Mwo[o?EtWSd.U$1:֎ny,6s]S7t:Nj6KaIQ?grcb^._;[7rI5ݬO-kǔdS0IRTRMI!բ?:JƷ-d>I}:K{dMw$~%u]NNhud*־NZqoiKܶo_$y_U$R&oWm{'\31\4b^'Y%c%+Y_Rw?ߓ+f/+ACσ D?z ~ [D?-gs^>We[)uSϳސ1QINQQRÊ%SƦZ[> V-*|o$UnWI7m/_m,9Z-Yj>ݷ&oDԍuIi$D}POIFN~_V|Ob>>ڞfN9}~n@';~uA`K=7ڿnnJйwnu} ֒KmU*ƿn*(?ݢc-S_EߺnٵA;ŭ׾ku;eHWlOKzFCܩ3F8u iO(0ZR{TEG߰DO-[[/tߏk35?{{kͤw,_G_Qz_xt>+[G,b|_u{u 5@ۢOͲE)_S>ᲁh5Xݚb9,j;~Ͻ~S˚㣟-E<;~=Q@l]?nݺ)ƶv1/Jf CeK-޹߻^۫IYB;YKj5_"SܾSSI?󜺡; 5j{'8 ~@!nh)e]_׽RN;PkOS6~W׽,[ {s#B~#JHg 5>H~ACsc`[0Ȗϙ4oe wQݾ݌!>{Sڎ*f_lNҶDDHoL Xv>Y2S?>'d?eҿ>u4}<?$fkd9M0po}uKm>i(3&%m3];{kV^D?s@d%9}Llj9N0֦}_{}/m7e{cBגk͉wnߚ9\ݵc D?4@8 fzG?u[}uY[w{ﶛ:M;5juk>HW>7ZCcdKUԱGg\H~@Csd !Sm_?v̱?*})Pv}ؘ2 sʗ۪3?vZGݜ,?g?SuD?4C8 Vak>#ǙgG2A`ܟ~!Nm7Զ7kFYxw,]-VmSꗴ)\OG<ACsKd5 `U!:+fNϕM1z@_ߩ2,Pe[Ŝk|J%RRsH5ߛGL@Cs[du `ֈqC'H')mMX`n%QSO&gi},ihXGDb6S>~מwx-~؜gG?zGϚQ}*Ds~"`jn9%^и}oI{Ii+Y{#miwoזK+?-!8~׃,>֣Ǜ?s6sc 3y?4M@"m)S&N, $_hmhɵrT4 M2tm?gEm;WOm76Qb,c’~c#\3nˉg %~ ZA|_.~x)όooc.XM{2zm?퐌;Y`ߜc7VsXr}<³g~SeBNۭ"H?gkpD?4L8 v>}o!FkL;{й^oŜ 8G?WK͍2d2+.i?kC?ACsd` `w<;ڿ}̵h#J0a`{/~`J#rok|o׷LguD?A3'Cp[wI$~k{oN#;FXSϚ[Y"(n D?4Q8  wsY?wǎ=w>[wSS/~ϼ}ž֔[<±~hn q@d?WoQS.on!NwN[\ޭҿ(s-y=ޤZ9W^KFD?Aay{}md+M{&LW^Kɽ˵k X4~5k'`=Z8~|G}/I˿vcLp/kSk-m^[^W~ [,Cpߎ=aM M?01f7`ξë#~V_"yc;VD?@{kǣb>[O7/}oL}[D]`{r8~1Ao}K}S̍T֭Ͻ\[3}%e3c\;~hn q@d?H(:51uZY}N.'~f^%c^[o}yM45 3p\ST+LKL;E}MnuAy[Gү{{x ^[1Cpbo؟!ȱ0SN~SνĹQ?[~z= ^[2Cp0YXo4k_KiRsRUR?rD?0=y=֜0}> `~:x}oq.}>oGm!8~`&쏜3(=ͭ!8~ EM^\D?4y8 ?~p}/TxhzX:} O IDATv {[L{D?p[7!='\\?D?4_ q@/' >߈<œ_Vݟ٣0lpD?  oG<AD~Ms؝8A/kd?@/|0~1?P3+  w|Y|'?   ~/2nj_?w g<5~?A_=sY~qHBHL8"3` @,C03c̗'s~A@ ~^` MS%6DL(E~?1"bve_ȉB? dx&e_ND?Ad?x,dW޻ڗew}c}.j܍H] +6nrS.JC!H @ Bq EDwc}vcA]M~sYkᬹc͹.r|>ү}֚9;.> ~##yOb >C ~`K3@U' np& {CA3pN)Cs2@ix1E8`)|Yb/W=Eրp@^ ,?d\oUւp`N~3vA=@8#`W"ppF >  :G@}A8`~8G8/@8#P <#pF !AxB`~84bp6A8# <gB? ~G@cH g B?@=@8#1@xD;pF cpd`~xLB?B? b?#~XB?Ob?@=@8#@80gB?<%@!8 B1@mC|@S~'_̪~@?==gB? xl?'סI @S=S|_џJ3 3N ~8{)O?GB'p'Cp> )gS 5_JT8쓅!8TA0p r.~ ~X b?sk E b?sK ãS3,Ou|@s&yI~ ~8)pv'7Cp> 9"_ Ndf~87^8[T~3Zx쓗!8AxD9Ԡ_G?Ch8]~8;CNO|@sNSI' #Cp> PSNRp*"CxCp> sv朄9&5Cl\@"Iȟ9`E8=k$=&DM;~F+*"֭D}1~y>E0x;!8ु/D㡪g7_,X-(y<G^21҄R?丝y ŧnv&ڧs_zBEluŀp[mM>达@!qge'%p/8AXfxUjO}PEDG@/{m_jZO<\8~+us h^^8qZi̒s59@+ [9'k9>>N 7]O}06M߿}87aLj`t1L[ץVW)/6ՙ5p׮]1'^< Fd8_ov9 '/~p@^zcsϗcnƞЧ5.gCk뉌UCAqf04YiPR\#Za :2Bhqi{O%M{$?!N8A `G=K!9SO>XĦ $ڞ`b%./ (ZT) RW`ijs$#st! B?@b?"2%};d* 8ߥ?S ϧ84ksap򻭦URa=!?:^`I=,HG>ul ṄߵfV ncvJmZ6!;|Eow>/G >!+R4}ES=P'b$ -yR0VN-Fjp_#0R{E"bltbWs'<" C?/ֽڽ,sg; /Suy^Hl-Mq0w?7j0L ^[8Ƕ:o[e!Zh0g:*yD;gb`xpv?}˔}<96Ÿգmߧ 🺢h=myЕd{5m-Y jRiaeN ߃.+'*fy< Is9o`*>?[;p;IEA?yɌtvR[A#;5~%=AG 4q7W(̾8v 5LY'~+֎6쬬S>p$p x"N}]rKS7S/߿̣*7S[Cjgͦ? 7,h,.g8# kIg+ &l>D?SI?6E|];N eCV]y,?dd眖ϐ991Auߐ Ӯ@|@Xb??L qj!՞Jyۖ%OyƟ!Cjoݿ?_]-0]wqPY3*%QvU_P,W1}mV<ĝpnĔ)d>a`ZG6_-6qC esUZpO*|"HW'O8Uvq>\ p*Bc]GM?TKB~4Pwz>L?TY^8<׮ 7Sk^YC}4a[h}H%dH6O┡ŬK]н1.; 6GX/凑^wVp b?~@x v౅X_ uBk?ֵnoW ۟GUNMưwhlDZaΧ\M33Sn)́Όyɠ*g R].K/x[mXiuJf7Agt b?@xl) :O.U»xmw/KSP>Î.]v^w/^QUxh5("?̃m hB4.6/D+" Yaa3իT/ ~Ax>Mz-_*o9{h /w ![g].{w=Y/\C/MHӺ 3?- \-Z+x{e312Y xZnk!@-xx,yv/N`NQ@kGmBuu}*";YpN?zʟ캧r{z׺bQ¡) _\q v[w 6wZսSȠTAXv^.. by%1\@Oڲ&,KwL{VTCF& f4x;n`` Win[X!1|m3~Ãp^f08'ii~Nm?{UQJPT|߯8n6xx;|b.%?ǘR.]Twニ`K-vsIx }GU^1b,h[L(eIWTnӭx>!1~#t!\KEo{KaY+u?Yf/=&Ƨ!ayiKa*@ Ni+8l?hM>&waZ{{jKt,)/"b]َg7Ǿ`?ư׵2U|͟%>8AY1.zc-".+['fkN՞s7|AMj-YUQ\Ir?.Z^h"4୮};|;}߼~8/ 0gҷ#ykC4~KS^`lTUΕa~`PX~hD0Hk$}-i.S^H(!Ti"m[w+~...B&}OA=I:'Zrb?@"6:ω =DڂcwN>dsup, ֮_Krg.{Q>@g]e.S)'.èzPpPG/xKՂgieG!:GֆSwG/gŦB >lz&t$ TUЯhHܧl41AG?q'|T>Jk*~|m!MX4({f) 6b9Ӕ^o_{=gAr2xlAx{pN?y  ^8" IDATԵJ̳= Kk8+DĜH!7'PޏMԓ"#y \S+.\gР&EDmUEK=u٤%$)ӏ5Mc}mFhm}жm0?:/|ߖ>HDDC*#K@yI+,4'*{Y?ڵO_6UGis_ b` (c5嫜?*r%KB ; yA.Z8Wb~iUx灟۫^P;5V2r)07|+P9/.4}e|(:)<}?GZTO)@aX7L6NcZYڤ_7g1rkX4Ev0=ҧ?R)2 ·O/..RXmr?^RAx\ekXav*^]8qi+]!wϵ}>ZXC/V;*M'e'F э?/,>r1F"]uy]9S>zė06NBhtҹs{8]<ݳ};L<-~$ԻRE1W}^2,QgFlf9juI7j4)A_ޠAzx]i/KƧi8'0~MӤ1`[u}Ņ͛7r{{Pܜ/~^& _AU1=sV.FE+\j/Z%B2l6]fM!_~mwww~W۶ջ;ym+m6y^]]ɫWl<%4 bW?}H%U)~h6PBN]Zݫ]H=tkL߹}v1үX+ڂwS۳RH6k lۡ46/)D ?:|RB֛ݟU^XGꪙR}i>g}<͋/ίO햼x!@L2H]i2Q= mێV3((EӤmzü%j/5}>Mt]'wwwumݝފu[R@{=}wߖOI6777z}}mS} {gWE3Ob? LN6|EULLy;٦C 9{u }+o{_r]o{)e9ùqʒѷ}]/0w?jkL-y{;'w7㡾!X{EF\Y?$Ó4y ? mDӷ_^^J۶v{{+ҶJ۶4^__͍}[ҕȑEx. <=|}඙D(xF mrSepK=K^2k]~~Ezl#_T .u_PjxȓρkW/i<+9oε`L=Y3F:& j} bu- D &Acd3 Yےp]=Y'}98|^~Хmp( ޥz2 gC2uF~] (R|J/ MӤ׆h瓺tPv20Ԝ~~1DPϦiRl6UZzJז4\__ȷ-A[~'wf%o/SBXʗߓ^ի[ElRcic+KE^0׆:)L3o?Knj?OenL[h\R3"w _;Z.yOl|r}S˽_~EX!aAW8ҵ~r6o /A14IGb| 57Iڃ@Ve P"iMaͬ~,/;)u{KIRyM4}]C}|֦`kP OAD>/D7Py1[k2 >wss#F...uuׯ^>O;Co޼T奭0ΓC?_""~Ft@šEXi*ʃ{.Y{ל: X% &XX=>?In~HI]25=r>dv=zYBR᱖Tc/[hZS^xo̧.PZZiz & ~9ɣr/8 RJKP!e9ڃ~jSSǮ0M BJCyWA"{#mimq߮j|[T1AS0HNC]ڶUhu]v7H~̋""]I'ol=S]곐 @þ%ڶHi>uC!]?;yMl^nnnRiF...V/..h!7oӟ!vس__V8N ~x># oѱ_ EKc]%;}q\q;ٶvGCC#8؈|},p=U 7Sɩ_k9߽DU]T[{VRLԂvo1俾{ێ3J&N hkiE/6 ޟUc 8E)>$}lz=;PlgO25[^"u]Wd)}cxzK c< /(P}NTW?>@'"IiS޶Ӿ][^zoL_AuQ3kb>,m5l߶Ўm-,߶K4M/[۶r/mJ۶u\^^Ņt])}f{SUUD3-8ô 9pz 7`:NL;j?ֱ_f%З_ϕPMď, ̶NVkq6UNR5g> k%8/c[^܌G/ljǫMfƒMiH+Yep[`17D')ְ)ZX~e2<tMhu|ߩ$ wwwõ&K7?P>AS4Af/ x~}<}{x?E_^;z׿/^IsAI/Jמ6μC;|V-C{Wx/B,{ I/Bшz QVW3{r'c'G#e5aMuu4.4XH o| &usuk)B6y?NڶU_gtb! T>]mX>B4B_]]\__Օ%f&} y捶m':BTU.//m.8#VB?iNBտԕNܳĸ(ܮk;A8V=ϣ/ g')>-+OKrK !P >)N9#o5xF يyB+OB-(TJT T)z-2C5XNAם b䢶O}v >ɮѭ= 1J!O# !@6aL2OfkFk!ޕa]Io0F{4~aCuY$Χ4"9s[ziF6Mz Nu4^]]YO6Mnxss]͛7zww'777Ozg~<@8}5}eMx?\?T|,#_h P_ʚ\ޚkw()tNk!"Y'SWay4渏rHCSר^{ᑥ7gĺ,lx-t\ F&r&Nu_M,YC,{?nK_$j8ak;4]b_NUSQbJn$7H?CnxW Pˮ bzsC(z׏a]'Q>Bk=^lW Ϲacz ůd IdOu'Kt]nY$/ .׮#2#O7\\\XAEDŅ9?2u~?Cg>cـp /"St$aYҽƵ} _aIc1m^Z֔ă?DJIuY_Rz\$k~ b_u=(>3 _fSNx ?Uf>^ũ$L-#μ Kֵ015 y|pjos5$'e $ae 䌗m FZ>M 3rˏ6AW_=n C4Obԅڞ~t<.u[U~γ an_WCk$?$/4)E?ЦtMf3xYuݐ2 c OB?.B Щ XBTEH|o|_>hir]~ֿ3vD2\K_z7 2j +@(~~R y)Hţ~nxt==+XedCyQJmp^Rѽ zQ#[ =J"2 ᝁ@W׏W__0ύk_Wd.iX5}rx?iaL|:d,ׇ/f6x'VGF&^_Nǩ6߽с3EIv )l3acGH؛O#um;NHbCf֧я?XnooeX==v~t B?y!YZMcyW\~m[`N>vX$s[]rN{jA |K4.U{Ho}ᜩn~=qo{hOr]c]]Ɠ7#?R>quVP0M+Hef^3 B^! D\+Ë&]Ǯ u4ls,ڂoKxyzbjZIS$t< a^7J9A޹Yc:;g}?N^71pYIw(m[B_^^\]] Sbfryy|Gw}.//V2Sj%Ogxl~8m%{=P}wzM{ & qh//Zr<G&kXVEOp2c&D8Gunk`[MS^ [tqx'KDy:]Se&@ջ? 㺰M,,K֞"?^p-xt7u0)q,D.zz`xХ(DV6>zxsT^ >1~cR_\` n޳zP=륜!=DxQ B$)3E,Hפ|)~A ܼǮd<2x4jm;meՕ]]]Օl6mVڶzJ߼yc˷]^~_m3L-' 'p Ő {ub݀eU .qѓU;pG.{Ɉ}sr,Or1B+8c8.:" *P2lv~ǽ '4gQ_?.tyϝa4 i͛3.]۶i% H4lR.//S8l6*2DW^Iu4m#| {v#1A8_4Yo3Tߑy,D~B`4 (ShxZͧP[D(w=[[~xQ4 yߏ!:u{5y6+ϪAOz^\s0\n$ 7Fl6lTU4ɀmẾl136D2 ~NKĵsŏ \V/ۖq/vj z,חTީ F/׹/ylI[dP,R^Y#tEE g ԯA7oQ:2CnypFٚmK^>=bC ux^y?uX4CĀ~~-EbHub a@BM۶Bu҇+QU,=5i}?ABkn(_U&Q{!>~&^dK} ΑSJpr>٪lq ܣ+v.k6S,OV_rGTә[KUhP7E2(6ׄGT 7MQ~w[Ut÷[O#0vєjZQ8zo0o539u*8)'7{Ӿ]y!oޮ>t1XéNdn57 י握 R\v^Z +7[k-Ci fa{v?' (_?Qd_¢W;ݡ9u gm Zȥ!+]O>}A/`W7_O@CZ sVX#(Bҧqz)@tFϚ@PUnHg#8o޷dkvXeE#sb>*~&0Vf?.ILi}W~$ʫ!]t %osڶ T" ץ9TUi^z%^kG,? <tXoNϾQҦjBc=W 8rKO/JRH}]B|SOɋxwzWߥ>[Xr4>Zz1'uwĞ$ŨTGcs4^s *&9s4zǵ^>e_0]og'/jL'W]B񾂵BƠ ȾƏc!rRF^ qq7V]EoB-m4]4>%ًޓ= s`z KB;M.I]L1K 7?on9fz%Ch7 *DȌ:ܿaF^`pC~qgj &q?yw]]ץ(I4M"\^^Օ^^^Z_ȏRǻvy0p\Z⤸Vz\,C4smE`v͡C\ϏwxvL'<%zm ޫUr߶&  ?G[Pδa[&BB]~ת2To_ڿ</xzA>a^߸'xoCCXrLVk/͟žXW_V_v{XN];fq"pvbf?(ߋZ9F'LOǺ:AZ(麮)W{i F)O˧k cy-M& {%[s(G8h}qzݧ45'_jh^FG}s%]]]i۶ ~G~d*Vj>ɟ>5%&XwpR(/֧EG 1~}=x /c2jVį&\JqUm C!ŇVZ sS$r vE~MolL1*c`G_3s{_꿷Ԃȃr3w>?ʙVP/oCh~O[mQh0&}%a[ǥpfv)P88/(\{k;lC?&x䢼9*Wjo)b12NՠnM[1LVlޮhn-f ϟ{gmv..ho|ymۡ^w_1_AIaw~$R d BxH4Ҷ#ΰ麮K͍[k y;L8 4 8*&g-R5<0O%S?y # ӿİӿv6݄նBCaOkS-։=_ wcWWWr}}-??pk|A'E73y9+~3/6]ζ&nT˜޲9(|e޵njkwu^ͷ)g|Nwvgek_هY[Z$za/3SkF?:,tK_CE۫(s{Rú}͞Js bMU-4E_ZZJoy؆[WCe¨3Smm0zcW 1{i'Ʃo$>Vy>q;I?ڝ]mN-qBk?/ okfM9o.Wo;Qza?XĽg|9}aRX5McmS8 {C$ '6R{CI׶m+777 /^^zֶ4Mo־o?T>#lsB? TD? kxQ9Tx^?ׯ![4̿UνƏ_yRȲ[]{oӏYWy>*m^K幈ց7HIξo`|kyDD͛7rwww?x^5Efɫ~u5BAC?j/WxOq5/R>lY^KVπz\F{m+ qnrt.={u<겱ΕU˱A<ėFmsQ DKPW 5U-Y]l4^,.h lǗƲϣhߟ5U/bquZ觕ˇg2䂏ὰVEiQXmss#Qj4se01h:u>-b>)6Ky -P œ<\xӽ5%LKu^-?}d c7b)bʂ{l- jL/<i|*+OeDo}X B?̊Rw/7{\ FfBX,+o|_cH\c~qhێwΛ.7}%٘>_Ke>C`[ku)Ծ̰gn@5ʯ*3Bis#V( ȗr/r?Zm߳풋B۸{n3/+ /}Z D{$usS $_+麹_Z[1L!t*?̡U֪NFɛ>^dBdѨ`dh0sryW-(Q- )`k[xxߖ:/#D>54ub_d~U~[l䭷қ裏yA#KJ?^|mYTrE)}S GGR)i??پ^o&bIU *^#EphΩa Rk[PuԷ}|k-]YLܚ]ctӕIm=2zoQ^H0=ka%ҋI̍Bw-xH: R6&\k!}5BYva~(R+ |/s^):C)H$Mu Q(H=~iO f6e!!dl{kɣݧHyեDu& _H+0ګ,[xefvdk4EHf&om+r/wwwjfvss#\\\ȻᆱsyN~o}Km_+ /~@#Q-"du&ۃ><螱{F&C//ȡw' ]~]w/Pߏ!:?_ģnZ.{ ˹g3>2)>@ץitn/ngVA.֩{uE5 пh]q45ÊPh [ߕaxrwAlɭ}-mfT(:ǐIO7~N!\ۈHSCtOwk։iHh[fЗ#1XB$+g%?hpyrz ?>ܛRH1Lߋ#>=_\}3BnH h Cf IDATIV^~-m͍m+MӘJ۶r}}_ ~?y;?x^ b?|7yg/ !6!-ݻʨ>*\\R[?!rN/$EuJ@->uTvMh(B~eF)؆rP+wuW̭ݿV5㒞/饜53TK&GV`E),=LEc)襚 {jKofmIK]4M3JuQ̧&4Ҷ0]׍E~?}Ə_+(CLE~NjryyE}${mעzssc~ZLo|`|B?$!3G o&gB[K,Gdë-Ǫkmkjߥ/YXtj^ōӻ $ޑTi{Sj"=W-=9vKΓmr``%>`[5ѭb Sz^QUӽ6?x}\DJ(oLym;93A\{C*!}΢g{=[ 1A4)C/8MQZ՟}TRz/e#96|% HjW\>@˵5dkr"x?o6l6Ydrqf37oݝ m7YB?x]%`mF[Ϲ,pwdzѰ>q*$LW/'ZV3:ۿg=XD(VuFRvL17 Zׂ WCiDS?B XS^ ^I pcе"gba3RGyðS~ڿ`3/}9 X(/W $ ΢`] _ʩ"p龘 "A4Fd5j UZ1l??!8Ռ/'~M?#o-Z٧MӘOQzkt t }AyΈ|jCGm?>/Ĕ]C?A: Y|YQc]dmfW_ט:ࡠѼՌ 1wdC >N?OXR^ҌVoi-ԝ%]H33UirЏ`P\n, >Aamf$b n^EHzl51͂KSEkSOΨ$3 f6mݝl6yooo-y{c)B?@'Kn슜;<u k__{X}΄>tQbp n@=ESSW:f3n%E]`h#kjl+;>H!lR>}ԇxͥ=ue5W^eKuTk'I`{I6q,*>5m)e-֜QBgg\{ >g/'$z6G# bRʂ+/ΉRJ(-,KIOB >߽ ?^4Wזߵe:wm[$k۶C[K)-梱}0b?Qh\0~*t-EZ[rI/maIϏ a9ZfEZ9wf:tlly3~^iD/Xz'h2k>`ћbaAfZi?%>їRbC% F^.UI\RK+u-fhd1|j]隃!7&I!cwkbz86w׎6!Bc1Xf,$yrkdئ$qϤh޶m4sB)gis4 Ya/,Hꅻ_ Zw1E/Kū+ÀM-cE{r{ #,.9nOد/EPBm}hA4=Om΃F9۷oVċ{ (B}o׀JPHMl+8@lH%G vnI|n}9g5kaϚkԨ1j9ϣ>w֬QUjT|;,9Yj{⫤q %&e37J9J߾W ['Yպ7MwxXw-yJ2 J@d>m"J dߟ!^z~AU ֕3)_h8"8.ߗ8 3gk%c0Fr 3\n;jFVمn }LXlymʔ7^J}y#2gP) o&_[c}I}NҾe}nS|T 7a+s~a ӬPaCqiZD{>{K~Gs8bz#w~Ỏۡ%S_%Ĺ Dx^=~pzq-1i3{B|ؒ^@3u\ٮ]u-w:x|cVy'eiA{/˾V}r5퇐O_pbV%sN,מ@C/큈hZ?a,=T&,{=w(exX12~弹Ԅ]8C!z:ډNt6M"AGbwkR].x˱s6 TJvvd_wQgκy%v ܣ `59c'kܳcQ;mk}MJwO"޳fxA3TR5D~?2UZ2]֐oN}Ll2s\;qf'֒WWֳ"#u>_e\pK%t` /+ (r@^ [kΛ=q:{_d\֢r.u>c/ j :@)cP{D;2^}O$$'};)@D_1Wk^3z>A cd?pdT$AE['GkMN r$Gz]R6Sώ_r*'[}Xgvّ Gh H 2|]̵5׀WahH;5=k吗Iפۍe&Q.Xl6AZl^k!R d{1x9kZ9*1T S~pA3/ Ś ;NT%Mt |v{d+?}Uj sƑߋ4RAO}mo @1U@~n!$᫫+IjE~Fs$^O@0@~8P{?(T@<=T:ojs^e˝νWGp$iCxё?ݥ;;Y"r.w1E;~Hl9KLQgx0qѮo:Pew. d^_9AA+7-hyL=r%;/n/33jμbm܉+{$.)?;*P'n ̹Rڡ35?Dn־05RIӾW<.W`8xk٦,q0 yas5^f'>V5%˓Xh1R1;oBmI13YDϊ)ѣGtssC3#}D?plNAsV;L?Vn#WRK &YLAh'X{垌fwS ǒ{sُO+{,1>y؄ݑ{ݱ sI*9!. >gR5]m-g~/Ģig)#o/+/ojEpB/iaI'B :}^1k{%H`^I5g}Wk95q'۝#_(P\V2cZ;T"e͉0 ;5I2CڟZٝςe*J<Yo&LYy%D6H8n,򒯮kkF͆?w S1?N?$%eYY?+B'_ySfe8`s299pȜs"#_5OOOZ}:Y;]{uV Ÿum$@ׯR_+xǒgH+IޞZ8sejY›_Rي8繧zF}h"kb*Iz,pkORݐOBtʤTp٧ ֎wb02ų9gL){UZML, BMdg#g}-8^͗_~c弹J z>sҞ\9g9D35r>9'{Rϴ8.?I1FekGzjEO>^{t/>@~l\cގ)dВX?;{ 28"5SBhTRs)`N  QASFxZƩ/SyP CHusKA-ѯ)؍n^P{JMe׺l2hU K_9E:C|oH&&u*J%...D vK:oy}NU^ D?pjNMe4$)sq0x@a" ]"uCeްӦxC8{yyX1OnY;~Ÿ3>BPm96Q,%e+C)=gu"X( IDAT&x \Vv:d|u>B:Wڟo#XÃAv?뾾ȵzj`/k]m,05ѻO|>yJlqE}h޳Hl `?f.!/ uZK"+~ճHinU%`"kǺ@vY5;@uYs1 Roi/t$|Ob)?e'#lv{Ziݞ?>g13ls[o!c^0͚2` sIL] lҍ̀#tZc춒K2ގVVjmOϙ}NQiK<{gz1[ͻ sd?pzhBP2{Z[M\vb?:ڎ' n~)'+CxQ{v~g>UQ(a2;S*cC#2V+ IߛvN? ^»R84F4YgRSg *Lz3瘃;!.^Љ!ʪ5}<_Rv^D/[ٹ73+\*@GpvץAocDe O߳#Njlbfе:IkD&/35e[K"(LX=WV E@GCe[Lj\I2u-oOD3Sw{6\c,x%}־ 39_s;AgilH20 syjE8΁inooޛ q_?< Hwn$no@jlYx]KuLS3C-ױm7JAz|Ӛ '+H|g<o(()ʫ#T?oP;lgz{^aeK[*5uMvuFُC"gu }7Р8a>[jg?9//lSo-ˋ t]=9Vւ{ tmO$$1&>7ED8TZQ(2#%.dx"WU+DzS akoh:E~ mLaH<#"+QnooYL^Sp BnO(ZSvnc{{9D8u|m2Y_^D x|( niJ!Ўv#C#[~εO>ڥg!/hȪr)?=0fWa c1^Wd&=Rܻ٩rZD֞cʙIgD4ζ$9xkԒޚ8 J` '8eeO6'nܙQwྌ }( dR FiwqEMFe{}76pCbXQFX8D&Ͻ( .I*ּL_(@dEzo"1ZLqqdˎ73vfCϞ=~"~{k?~g 3`f3?!+ߕp qśX!TMo> y_bsOCү) Bиw+_԰IK]zOǨ Q&ǐj#E=:~l;ʀ{yS5yT 0d8K٨g'3[Aqmggm{26@\v`+"$dխ}V-sFnYO]` eM{" RH56 >ZYuCG=^Sݻ~KbCQ`]SmzYyʝDĩυtWΗ?'̙tNrYFkRI&voX ^u 3a!+%n EC6A3?Eo\d6?rkYDɱ co|hvw/ Ϙ3џ| 'f7Bu8D:]4sPpה_,.t^4vj?Sf{❘'yӀAϊ~=A̪=%YϗGe'uj\S/tO!:{`YMǦC2٪i_ ( ;^v~)ouܿ^` :G,kUD &:!9j "f3lIџ'_{8۬;y9ewюODzM.-0g&qquD2O}@ aI[ۚ@:wqEDцe,`x0#~;P!(ڰ 8_K$x?g?TM`hꕠSB;`t%(z aZZ&ZL'ΒC67>mytSE_^gsI9LG7#e:z rE SD"etvI }j`Z0I{5yk{uЃU֠FL>w^g97'g3*Q+{X"#'L$Yp}"g gr]X +xd/2Y}aV4'"ޟM吔?3ffS1vo6zxR1#~_(1:Q?pɻPR]gMe} n&1~AV}cDtѰ7֙ ~z与zZqtZTm1̶OvO9{jFz J AEQ@C:3ZY[-}\*5|R0 DD4<#o6qC}͆pO+ C~ad5\O)_JyH)!ۥr?&4S96b\ thDV`]$J@w29.*v %WJKW/(#Yuf,楶H‘sONߚCT֫0%3׆O9)oYV$*A`_6U{BfgJF w!Ƶ2^|J.ۼZҵM/}v>j*.e=dgAuǕ¼2~jȴHJ8d0MagBeëgGl:fK:{ܲ S522ӥ̺! {4-qm0fs-?KҎ샍r Evż Կ@mCbG=gk]߲XD:"s~W$e79^V)wԯ$?E묥 RH$}Kړ ^{gY`lyzO}1FnluQ"aZͶ};a;i~!d?p9ժwY\W/V:c3GK]{)c5nOԫ'. â}[!7;ǚ@m}Խ$Bݻz0b}gԽ!5oVsa#[[~_1ŞdTrޙ׼5>wjeQÝ5o*rR$ %*ATJ>*%~|AEob-/ 8[LuJ 2Cfk?ǑCH[J?5kOZ|׺%A Á&̹<{z'齓⮐u9>_6MgeP{:\Y!/8{0;Z{.J)φ}Qylvsf ©e;>N=Uo]_X r"!~O~.:"_uHg| µz|-ykbNj7}O% lm_@Vʹ#[ܭs 6|BKHL:?$҃| ̤x"ͲA%0\;ggMK_<̜҅1?YgsִB! 9Gi`R'#̲ba._ugK_IR1m6zL͆?NDěf#=zhkc3D?xXp q~I7챦1'mۄ٩eKӹ/7"c(?˒k*}dx ةjdSmOxȐO{Q\ {܌L򯤌~qv<|֩QD]J/U~F))xξȶ5y4-b:ɚ5U|0}Mjث^},J* P+B U4V4!ٓ-Vw^{mZ3SοF>[$'`/ *J0T kk>LRl*Xs(-TF.Y`ZI?e zf3۵ni^,?햶l'>a  S&^VDeUsܕ ?; Vwއc!K麄.3p6sVW$==vRΝS5S_4:A]sR#~6u2=;/VǷîOE;赶Km=J پ8lD;v@V#`c6!zTp{Vt{Xl淣Q*FM֞X(c[^eW\G=&E@@*h, t%/-u *kה@^c$NY[gKmXJf^$Ͽn,K\\\8s0 <}/ $/纁~8/@ oҎ]Ԟ9^ο{ i>Z @~g^} ~>I핲H5^r, V7)|ǮU\|6Ѯ('?l|s4־P{^Kh[a% uVAg,ݪc^+'ɭ~e=u={)VlQ(2;Z*&id3) sRT:T*_d}r,a ),pQ#g$_",ٗ>GaϓPƜƟ[Ǒ10 $ת>eoTK~d?pKKL]Qퟟe.K[IVZ:,SKd-vWf|OD;LkYǬS^<'p~?oPtU yW)B!L_."+א`Q9\;%1ػ7zAwIV0: 3% X^ЄDN~HE_lH7?jY'3bni٤~qV H6 @///i^0 BgϞ|@>V+v{߫J F&j=][j3גZTYt1_[?B?%`Hz#t/+)[ozDjgN%~4^L^&'gݮu|CMCi'%.B}ȏ^FVJw4|;XwNPsoE©~jYzttmE{ 4\ޱ|Igkv/ ]ۭXJ]Wn^Vl"C,rvu-yU,rk=R ϼ=X׭\MQ#KU OR{vWbK=xJ H~aj5$2]LD#=HMbޟtssCDfzM|\N;)?xQxhK\c @ڕd= Z6Hz2^n[ӻ pڟX*:5_.dضjߒʗM)~ՉU;5dzf2lw~w np-ّ.{=ϥ''^-a^v෧Hv ;T-#[U |RĿ*-a'4L~hĜy^zNr>fl {gH@ueE}%6kr|8 }z+#AVj eԯV+N仔Ǘ%d0 2Ǒq$fl1ɶ+bf~___vjEϞ=#"(@ttw'Wg~E~@? NjVof3{U;?&nENϵj=84>c+_iW9>bB!*` RWe`*ئgT;ˇ5Htߩegǎ\V7\ù4B<;#/}Ay:˓~+y3O;I_#kca%Aꩡ,tK ] 呿Zf?{VlԖyw`Mד3_Mޫi^p{KAcdf *e]ގY ~JI$[r˄P1TO)s>ϟP5z#˾TRDG*l@)h^$'1>}gonnh^gku(~8@/@/j vuɹ.{S2QvYO!-u=Kk{kO~Z% IDATlYPĹ"_,IfeS=Z2`&9W`Pb"M<7.@*1~s~}cj>ƹ .KlQ+ُG`;6wɂyϨ'3+kcW(cH>S^V+ք[w5q!<9 \IE=/+\H2\|VerN$z,5'9z=Ƙ䷓Ӛ믈xS5AgKD&_d^' gb?6㼏i̻H1F> bY1*qr͆a4#}ߜ7ߜ㓟$Ji{~8@/"@/,^IUV5e;@;r ^xg>:ydg!Po7 v|;ә=!_ @gP> ݸ"ǒg@|/}v%Dn S19Ol \#U67/6aǸ}]5^K&u/LeIRT?GַnnnvRzD?  =^, )Z.,dkCvƘ@As=5~*;"<#Mˌuk`+5s5k4C)$X%ZkZH?xscjد}~u giaxsǧճ )8j(+ժ 88cqizeNg>}J8 fs1/D?  =Yf֋ʄYSQi^?)\mdP`D?QA1FH*vDlŎ[e6qI[揂̓ŜP`pVlFYT3| 3l`+U|@kGq{s?ȯٿڙ}yG%eƞaz*ٓT_R. Y*{=s2HjFܳ wݯ2\ُ"ۼfV)xReKDNuO 7u"MБu(35%Rw͆q VyңG?~DD|J?Or7cD?  K%~sGX^2I_u{9$%!K9Q_:#(q=]őEgUH1Is^IjI'frVO`]^)J@LDB;5kJ ,B1Au+Z{+V%DwIt]JZ>7tU!eLj[%uJ$lbniY:b4 Cݿ^o~s^f|D?  =?7W)>;j_*!]MR|I~O! _Ͽ鴒B7ʮ' SSs"[k;tk>SFѲWKړQN@f yzI2C_$̭Rz`cR@0vM ˯[g)0 "'0 Y0 ^vVxq6jEַⷾb??$#~e~¾Vqw'Icg/m<џVƾgZˀ5K_ ZfaKrܭvW)YVVuUyDR{Ǣ|?gāyYmEj ٟ푒Ϻ6>3Y>/H~6W8JvdeJ`쁫!0 4 ÜҀqUiki}&/..hŹ~s)P @ODaWD?  =Y 0'ҒEx@~ sk)'{-&O:9]!q#=Ex|' ŗP,~,uqd?3}rs_N:WԮ.#]ˍ3m3G;~%$/ y>`{8o:r( ϮOIJ_EL ̐A 0zqnc $EloN ~8@/+@/7O#OUvcLIxn=T>V=֞v3Y_m-Xꃵ9nF}MBh7X7 m~ZޟLgDD4c!a.%ǧ(yMJ ,sՒM%Y12Zw2 v?,~hMT&~Wu eB!r/޳zt~C_ߙna/y9/2 ydp{}Z49?}kِo[D?V3ׇa|?ܫJx 8׹|?<̥9&-$,%c~?X`gLWm~n/ԴDz.NA!k{_:k DrY٪ԮЂgZ+5{SkA^$µ$+߭Wm}[$v>bZ?It9z̒$ 3hCϛ "Wc"kY9^"..e˒)Gg\S^XWW /0 #vG@F"ڱXC"qy9\]]5>hq!c^z曛CɃ. C{ݶjY-Rm3$oImoM/<}n+r4'VۡF҄beJ/7M T~-Vd)믳R}&+9c>T`˰J{U}ɺJ_!t #|*02N]KH#8HPb>nJ}%C#d~ %W=63DxL{K1`Ôv yLYRwg[(FYx7~NLB`x^ӣG8@aRO1Fz |L[aw^op^^^=$˿1^/c,$Bі'֚k~'޺| #͐Qd ך6+έv 2۔_57U vϘܝx_~eat>WӤdUxgrHQZmƽjٺ=V)nӺ>z˞w|^D^YVR^_՘|-ݾ2uSߛ[E@)Hi`NP)z hƗ)Wm$|ߘ'qHxw*aI?\\\́"Պ.//?~L裏25"7x"7_pf^^]2t{Sp"!Я,8n{3dGg+$;>7RK K.,KmoZ叡,A*Y[נCD?a^ߚWPˊ{45l~n2rN~9HVfsI$8JՖgog*JAX-QdqOu &)'yH, OX_9msTTT>OIj2_+3 5M7hB?#'%HoNwZID;AQ360e\y]\\0 tqq# "z71Sߙ JgߑipLX5%sEYpYx5ߓ~'c%3*;{Y။fE#W74L7/V묔Ҳ^Ef~]<;aI Av{l֚YRN8jwzuXN1[A1%+3TcAg{SɁFlsn="^e[%RԷHe8,|Ra(l!{uvJK~U~8鿴{uFjTYV~쭂քD^J/޽Ė ۱zva_g !_nzuE9PGiI2؃k9iKezϦ5'r-¿uY rk1"Wq i5$Vcݳ9ĥO~㧞V2@mdIITnZ5ϟ>J?Nd陨1*Rvn~lV+2l/euiC)H@k8Ϋ{1;/O<1ӱ~  ~xUkDV>sה_fzVnnH76e,*MhbbEv<[k` i}^+$ ?݆}s ]%ez{)E̜*oI yGnLt'uuU о6햞={6o,9^^eKpdd?Q^~rY^͢K;bO3\ KS ; [{U l7wnf_wR|/K;3ۅJ@!mI^}U]ƽ?^CEl7ǪQ7{sKPHTZ%PHp,bI;gCi7ޟWޛŅ@DeD˩ywQѝ>',Y]@c$ݟ{o" l6lhGd٘|CniueHzs݅ ~xzqd{x/o{EDx0_%<ɴߘ"/Bp&3o9o "b.%\gb\<ߡ԰1&ețKgCª/PՄ]ٵ}o@^R6];4{ _A /)-RM4leTH@RPW_-LUR0|6뀇y1jq2OV^G} AS U i"41DΧuR)B< \1)#d>K)q=/+O d?,IcJfSO@ПkCڏ;^(}YL&x=Ts *Ec-q,k"YL|YFA J^e[uM'dsVDy%] #,N^{]"ksϢE ><iQc^/9nTs2ymb5;$OD$H9#;E;3ѫݮ5-e%Y&☵-U)l$]۵uw|$|r}%IaRǑq? :Kb,x{ϗYe OO>'ر~g9;  `_'e{K]9it "/BIa_x,35m1VV3pz,G?[?Wyu֢=9hA`ӉORI"[[^0뚘5"hAf*?f&-ӵu&y{U_SvQS=x2[Dz]"Idr{vj'Ѝ< IDAT ~Acc 1 :IkH[D[[}sZ;\E(w>J*2+~:W|~OY?eK?KV+mS@tqq1oj*^)$?<@ @[Y}lR;$y$sUyNu YtTYB?uRMg{'T/C},]_;bعsd H|z'9Rr* .zDȲ6ɽ+ `Ffx1. ImeWz K_\3}g=7Z1E[m:VƦDv2Zf9~13m6q3>}}A+ d?G':t}q޸7|J+{P¯IwDGjҮ,@_I-L7jBmm;TuW5! . -?YR¿7g%-BѪ.>t=wVR#[e⧯{K,=};ecKZ%料z^*>|S ;crKޙ+S=8ZJ#LG9 k9!fU`f7NL?hgvO;[Y(ݿO?1ưZ YϧzQe${Yꝥuq_ie`73λΰ2;3`5EVt2~O\_'KJhfTJ@oSs.<Z&u)cKR#޺H5~^O_>7 13m[nIŸ...xYѥj+ؿ,$?<< \8r?xrH&F~Hbe`'%?C|!I壞zϡP7*6)[;.}b&w莘KD]?󪵌l{ICYd.J zcU>`I>,X 5GZ{9EDp1I {-H|"j'"c~]K֣AK>N!+,zF3O~]V4 vS?04dw z䉥M~c~Jw$?<@:@}[?j!xdf¯o[Tv|CI}k6mCm/#*~(wM_[YXY9R>!\-eܾ̓'=aJ)5^ 95&Dcr=Ա>=rE^soj`z+SJ^:HAۙIS ^;BVY,lU5|q=P _3-ҟ 'I Ioۙ~6]\\̃WWWBaq$"f!l6s_O>#/<~JyoY.@@ %Źkv{?v_dnwf]gWp]Z*dqE{2 Jzm e"DfY +BiDJ%Ûgj[Ľz7(]0Yl˴OAV` 3s"H״^]__G}1</$?WM׈{~~zf Y,9_(,y 2H$8:hjb Q?p/q sAR&nGn˪?UjcVDepk5{@|A|To]J ISsίfMmscMa݉dY[Nɟf7%O㚁0rd}D-o/!y%iiLQ`a2[a ".// ;!@r?AtߥodIMX8Նr))gwKʵ, 5|>9+(L" d o!4YkoCgAJ`%8lvq| [qE:D$uV5ڸ,~TB~s<_)doV+W"շH9S]fcQ'^ ~8}耏op ҘTD#XdhԏD?H0Ĵvl2 oo7gyƵ=}@ Cv@İ/b'_v mldw̍~Tl1{+p/}Ҭ JA<&8#c#]%LJ(>_ܧD8u&0l,0@PB"#=Zqi^σކ#_]]ͯ@Ǐs9$%A W `9@0!eDlţwԌ1yLDzأI{q۞Jq,r}=Yџ_Y{S2éDI:K~ެQH0 V|յ;z;ugb\a2gK s`Vs"㞝^.&@Z"S|h1m6\k!z׸̣+iU@@*? ꍌ5;Umcʟ'5vR8 .`nOV+Ӻ+}>gFM/C9 ,ۢy%B5& ٚ5k1ՀwhmmN?o5k_Ksi]1r T~z}s_S jZ1ѝJT0l{y$J}j?d?8+{O,D d<`n=EA\˳ePk*51AϿ8Zu"df&hj/g'Xfw㾱YfWHVϺ~';Zķy:y vRqL|P+s$ H0Ho 3e@SBUv%}]^^&j >}Hco)bD?cD^K:,nkl'qܐ}䌛5z=fw@y$lE< 'Yυ;5[ 9Ll `IW?xrYm%RB^҆%JFpBuҘlיtdƺ^d!ßSm"YeAZ) }S9 ahGRyaj_j{ss777qK|ŐSS>q ?d?G!ITCs<$[9[{gozo5Kkj l޾7dFn$C,<۫+h;Z ?>h^*-!#Bg5 8.ۭV+^V< l["q#H0pʬ'ڑ"[>ur0#𿽽 WWWżZ_ ?5g>O$?<~N^%=Yk}r=F#>ypRuSB~g^_*L]]\|` 97/=pD,ˌָ=c4F9e2O l_N?_Kg > "R8ζ23m6]ϢT%:h&"n{: $xq4P_onkвNRn_Ue"UV>'qm}0׽dkB1ɞD'}\\&`)}JucY8S70gE >_ry>X> ~8>@p/89_T ={x|#C脹=T@,ƞ[?O@Ʒ&C!O/CNkIDgX"C:$Q-͵^PYR O'e*5-W<%/sw>}XP!d1v[y&#kl<zUXje ^~逺ushç˻gH~x5NJ{8H 6FLYB~Sv1w lg_ݴ^eL'"6e˧l",c#8f0V/n/#o2c{Ze{ϝdWIr>DDs&}j]{zVnɗ$,[.//i̶I!L"@4#'?ay h!ٮ(8ю9ҳ ^-d?gŃ$k80S{faĬkC}mrn}=ZZg߹Va)T=c F ѯ˗ٵGǮܻW*H]J7H-2}G|㣿.WQd?g/;{Mx65l{lk` ZRߒVdnKI4>_Ky6 '"7^f]1:̻C٘j[M=ff?dRR_5gϫ+}$Yj603~6z)$>ԟ|<BY}6Kp䘉~"Sxx?uuIwL䷔2$$^vhX"%C$veM|[-lk@t5ߴ^zo7\~aây]ҿ\9+RּZDn={Ϟ=sD xLykG_Qd~Ndkx\/#/]>G{l6 xj)H K 2,zM= ?=zqvK&iƾZC);\^^zaj_l[Y~9[#R=BDKqybEr(D!,?~8G:|swyp^d"= Yg}gjr?heSFkةznO!LpI>c!A>K'u=WmTn"fvK(o!ƸeBǏr![Jv<} _}X IpkbϦwG~ ;{K#k{ۑ۶8$ Eߤhc4.&E*76d k$E}W3#QƏ^ֿ} kygիWp{~xm_iO56Cx,=fLy{߅=K޾_b?\l\p矵2_ƻ,vU`-^3qz2wXsgpsu?ǰǭ~8$U4_sDž?wa_y<4e/2Nw ޽p:Ƈ纷ns^x1М}[=\ǭ?(%} %TXi\Won[s?]B;םRgo_xL)49ysv˝2X`ms]]6Ԅ~8$Q}c@2KBè,14[slm|fe,=ΉCe.b?[ӡ8Z-xfEtAzn55hS_c4|K0{,k_r:WBf݆[:[o>xꏃ"}^b(1odzl qMa٩!DϾ@`>&k{ƎcT=}FB?IV3jh1\x:C>G^Ot:>% G] D?|T9];a8\>ȨKM5fDNrO}5B?MyA({ 78rg_:}a>:' E{Bl]a9vopXY:K~v/K@DB?An;v(tOˆ[9h∹sބb?0Kga 3|9t{nkIDATSO}D}WB?EU=Q-y_S~^/Rʂ(杨~!MSSc*~k2//;`mg~ao,hǤ+ǵϵ5 $܋5G~a{bl )d^] 6.$$@~#aXK[SkhM1>/H#usұ8>@B?Oa'ܝ YNTܹm^˼pZ810 B$S=9xqXzs黴ݷ_  :'3 BA2_|tN<6_  :&3 C a;1Zpݒ85/} o?}SEyNuφp+ﴰv]KsR@~j\.WD$]v)vK;ܼkE~KL;B?@pB?p  88MHKNj #' @m~}~~Fb?vB?@pB?P+` 8NXGNZ ,'' @k~yB?@pB?": 8hpLxNNZ'<&' @b?B?@pB? 'ш@~~ "D%~GB?@pB?FNz =^@/~~'b? 8D''=Ȅ~~Wb?L"މ@4B?@pB?"'| Q @B?@pB?cb?: 89hpJNNZ$' @K~~e~B?@pB?rb? 8`LNFj%'l'5 @M~~4~B?@pB?@:b?P 8 -8INC"'#G҄~~~$ 8 (EN(GJ܄~~~ ' 8b?pAN8&'OR  ~~^B?@pB?@}~` 8Nb?P/BN%'O K %~~-B?@pB?@{~`&FNh\"'M'~~~  7 8 &%'%@~~~} @?~~~ 8/b?''G؄~~>/b&@ 8~h%b?KN$'0G "@[~~B?@pB?K<%(.@` ''uBz l%@~~Dh6d$uHAz "plHH %''@b?GN !'-1`%~(KN%jB?,%T?f$@~B?@pB?!Z38Z⵱`"p8B~ @~j m<"P nprD&PTN~j$@~R;9O" BLvv@nvhrrL%vPRKE~F!}@~VnSB?E?@~Z&PK!~L~tLub?HB ;CB @$b?@4b?@Db?@Tb?@db?@tb?@~ ^@B?= F&#h@~IB?=N"4D~ B?|"1ω@~L$ub?P@U~M!2b?P J~0B?l#`;(N}~(b~HC N~ +l~C HN~ )d~(CM~`̈́~8l"q~`5J*dIENDB`Teekeks-pyTwitchAPI-0d97664/docs/_static/switcher.json000066400000000000000000000007321463733066200226520ustar00rootroot00000000000000[ { "name": "develop", "version": "latest", "url": "https://pytwitchapi.dev/en/latest/" }, { "name": "4.2.0 (stable)", "version": "stable", "url": "https://pytwitchapi.readthedocs.io/en/stable/", "preferred": true }, { "name": "3.11.0", "version": "v3.11.0", "url": "https://pytwitchapi.readthedocs.io/en/v3.11.0/" }, { "name": "2.5.7", "version": "v2.5.7", "url": "https://pytwitchapi.dev/en/v2.5.7/" } ] Teekeks-pyTwitchAPI-0d97664/docs/_templates/000077500000000000000000000000001463733066200206345ustar00rootroot00000000000000Teekeks-pyTwitchAPI-0d97664/docs/_templates/autosummary/000077500000000000000000000000001463733066200232225ustar00rootroot00000000000000Teekeks-pyTwitchAPI-0d97664/docs/_templates/autosummary/module.rst000066400000000000000000000001661463733066200252440ustar00rootroot00000000000000 .. automodule:: {{module}}.{{name}} :members: :undoc-members: :show-inheritance: :inherited-members: Teekeks-pyTwitchAPI-0d97664/docs/changelog.rst000066400000000000000000001026021463733066200211610ustar00rootroot00000000000000:orphan: Changelog ========= ************* Version 4.2.1 ************* EventSub -------- - Fixed event payload parsing for Channel Prediction events ************* Version 4.2.0 ************* Twitch ------ - Fixed Endpoint :const:`~twitchAPI.twitch.Twitch.get_stream_key()` (thanks https://github.com/moralrecordings ) - Added the following new Endpoints: - "Get Ad Schedule" :const:`~twitchAPI.twitch.Twitch.get_ad_schedule()` - "Snooze Next Ad" :const:`~twitchAPI.twitch.Twitch.snooze_next_ad()` - "Send Chat Message" :const:`~twitchAPI.twitch.Twitch.send_chat_message()` - "Get Moderated Channels" :const:`~twitchAPI.twitch.Twitch.get_moderated_channels()` EventSub -------- - Fixed :const:`~twitchAPI.eventsub.websocket.EventSubWebsocket.stop()` not raising RuntimeException when called and socket not running. - Added the following new Topics: - "Channel Ad Break Begin" :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_ad_break_begin()` - "Channel Chat Message" :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_message()` OAuth ----- - Added the following new AuthScopes: - :const:`~twitchAPI.type.AuthScope.USER_WRITE_CHAT` - :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_ADS` - :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_ADS` - :const:`~twitchAPI.type.AuthScope.USER_READ_MODERATED_CHANNELS` ************* Version 4.1.0 ************* Twitch ------ - Removed the deprecated Endpoint "Get Users Follows" - Removed the deprecated bits related fields from Poll Endpoint data EventSub -------- - Duplicate Webhook messages will now be ignored - EventSub will now recover properly from a disconnect when auth token is expired - Added the following new Topics: - "Channel Chat Clear" :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_clear()` - "Channel Chat Clear User Messages" :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_clear_user_messages()` - "Channel Chat Message Delete" :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_message_delete()` - "Channel Chat Notification" :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_notification()` - Removed the deprecated version 1 of topic "Channel Follow" Chat ---- - Improved recovery from broken network connection (thanks https://github.com/Latent-Logic ) - Added :const:`~twitchAPI.chat.ChatMessage.is_me` flag to :const:`~twitchAPI.chat.ChatMessage` - Fixed parsing of messages using the :const:`/me` chat command OAuth ----- - Added the following new AuthScopes: - :const:`~twitchAPI.type.AuthScope.CHANNEL_BOT` - :const:`~twitchAPI.type.AuthScope.USER_BOT` - :const:`~twitchAPI.type.AuthScope.USER_READ_CHAT` ************* Version 4.0.1 ************* Chat ---- - Fixed RuntimeWarning when handling chat commands ************* Version 4.0.0 ************* .. note:: This Version introduces a lot of breaking changes. Please see the :doc:`v4-migration` to learn how to migrate. Keystone Features ----------------- - EventSub now supports the newly added Websocket transport - EventSub is now using TwitchObject based callback payloads instead of raw dictionaries - Chat now supports Command Middleware, check out :doc:`/tutorial/chat-use-middleware` for more info - Added :const:`~twitchAPI.oauth.UserAuthenticationStorageHelper` to cut down on common boilerplate code, check out :doc:`/tutorial/reuse-user-token` for more info Twitch ------ - Added new fields :const:`~twitchAPI.object.api.ChannelInformation.is_branded_content` and :const:`~twitchAPI.object.api.ChannelInformation.content_classification_labels` to response of :const:`~twitchAPI.twitch.Twitch.get_channel_information()` - Added new parameters :paramref:`~twitchAPI.twitch.Twitch.modify_channel_information.is_branded_content` and :paramref:`~twitchAPI.twitch.Twitch.modify_channel_information.content_classification_labels` to :const:`~twitchAPI.twitch.Twitch.modify_channel_information()` - Added new Endpoint "Get Content Classification Labels" :const:`~twitchAPI.twitch.Twitch.get_content_classification_labels()` - Removed the following deprecated Endpoints: - "Get Soundstrack Current Track" - "Get SoundTrack Playlist" - "Get Soundtrack Playlists" - :const:`~twitchAPI.twitch.Twitch.get_polls()` now allows up to 20 poll IDs - :const:`~twitchAPI.twitch.Twitch.get_channel_followers()` can now also be used without the required Scope or just with App Authentication - Added new parameter :paramref:`~twitchAPI.twitch.Twitch.get_clips.is_featured` to :const:`~twitchAPI.twitch.Twitch.get_clips()` and added :const:`~twitchAPI.object.api.Clip.is_featured` to result. EventSub -------- - Moved old EventSub from :const:`twitchAPI.eventsub` to new package :const:`twitchAPI.eventsub.webhook` and renamed it to :const:`~twitchAPI.eventsub.webhook.EventSubWebhook` - Added new EventSub Websocket transport :const:`~twitchAPI.eventsub.websocket.EventSubWebsocket` - All EventSub callbacks now use :const:`~twitchAPI.object.base.TwitchObject` based Payloads instead of raw dictionaries. See :ref:`eventsub-available-topics` for a list of all available Payloads - Added :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_update_v2()` - Added option for :const:`~twitchAPI.eventsub.webhook.EventSubWebhook` to specify a asyncio loop via :paramref:`~twitchAPI.eventsub.webhook.EventSubWebhook.callback_loop` in which to run all callbacks in - Added option for :const:`~twitchAPI.eventsub.websocket.EventSubWebsocket` to specify a asyncio loop via :paramref:`~twitchAPI.eventsub.websocket.EventSubWebsocket.callback_loop` in which to run all callbacks in - Added automatical removal of tailing ``/`` in :paramref:`~twitchAPI.eventsub.webhook.EventSubWebhook.callback_url` if present - Fixed broken handling of malformed HTTP requests made to the callback endport of :const:`~twitchAPI.eventsub.webhook.EventSubWebhook` - Made :const:`~twitchAPI.eventsub.webhook.EventSubWebhook` more easily mockable via ``twitch-cli`` by adding :paramref:`~twitchAPI.eventsub.webhook.EventSubWebhook.subscription_url` - Added optional subscription revokation handler via :paramref:`~twitchAPI.eventsub.webhook.EventSubWebhook.revocation_handler` to :const:`~twitchAPI.eventsub.webhook.EventSubWebhook` PubSub ------ - Handle Authorization Revoked messages (Thanks https://github.com/Braastos ) - Added option to specify a asyncio loop via :paramref:`~twitchAPI.pubsub.PubSub.callback_loop` in which to run all callbacks in Chat ---- - Added Chat Command Middleware, a way to decide if a command should run, see :doc:`/tutorial/chat-use-middleware` for more info. - Added the following default Chat Command Middleware: - :const:`~twitchAPI.chat.middleware.ChannelRestriction` - :const:`~twitchAPI.chat.middleware.UserRestriction` - :const:`~twitchAPI.chat.middleware.StreamerOnly` - :const:`~twitchAPI.chat.middleware.ChannelCommandCooldown` - :const:`~twitchAPI.chat.middleware.ChannelUserCommandCooldown` - :const:`~twitchAPI.chat.middleware.GlobalCommandCooldown` - Added option to specify a asyncio loop via :paramref:`~twitchAPI.chat.Chat.callback_loop` in which to run all callbacks in - Fixed errors raised in callbacks not being properly reported - Added Hype Chat related fields to :const:`~twitchAPI.chat.ChatMessage` - Improved logging - Fixed KeyError when encountering some Notice events - Added new reply tags :paramref:`~twitchAPI.chat.ChatMessage.reply_thread_parent_msg_id` and :paramref:`~twitchAPI.chat.ChatMessage.reply_thread_parent_user_login` to :const:`~twitchAPI.chat.ChatMessage` - Reconnects no longer duplicate the channel join list - :const:`twitchAPI.chat.Chat.start()` now thows an error should Chat() not have been awaited OAuth ----- - Added :const:`~twitchAPI.oauth.UserAuthenticationStorageHelper`, a easy plug and play way to generate user auth tokens only on demand - Made it possible to mock all auth flows with ``twitch-cli`` Other ----- - Added :const:`~twitchAPI.object.base.AsyncIterTwitchObject.current_cursor()` to :const:`~twitchAPI.object.base.AsyncIterTwitchObject` - Renamed module ``twitchAPI.types`` to :const:`twitchAPI.type` - Moved all API related TwitchObjects from module :const:`twitchAPI.object` to :const:`twitchAPI.object.api` - Removed default imports from module :const:`twitchAPI` **************** Version 3.11.0 **************** Twitch ------ - Added missing field `emote_mode` to response of :const:`~twitchAPI.twitch.Twitch.get_chat_settings()` and :const:`~twitchAPI.twitch.Twitch.update_chat_settings()` (https://github.com/Teekeks/pyTwitchAPI/issues/234) Chat ---- - Fixed timing based `AttributeError: 'NoneType' object has no attribute 'get'` in NoticeEvent during reconnect - Ensured that only Chat Messages will ever be parsed as chat commands - Added functionality to set per channel based prefixes (https://github.com/Teekeks/pyTwitchAPI/issues/229): - :const:`~twitchAPI.chat.Chat.set_channel_prefix()` to set a custom prefix for the given channel(s) - :const:`~twitchAPI.chat.Chat.reset_channel_prefix()` to remove a custom set prefix for the given channel(s) **************** Version 3.10.0 **************** Twitch ------ - Added new :const:`~twitchAPI.object.ChatBadgeVersion` related fields to the following Endpoints: (Thanks https://github.com/stolenvw ) - :const:`~twitchAPI.twitch.Twitch.get_chat_badges()` - :const:`~twitchAPI.twitch.Twitch.get_global_chat_badges()` - :const:`~twitchAPI.twitch.Twitch.set_user_authentication()` now tries to refresh the given token set if it seems to be out of date - removed the following deprecated endpoints: - "Replace Stream Tags" - "Get Stream Tags" - "Get All Stream Tags" - "Redeem Code" - "Get Code Status" - Fixed condition logic when parameter `first` was given for the following Endpoints: - :const:`~twitchAPI.twitch.Twitch.get_chatters()` (Thanks https://github.com/d7415 ) - :const:`~twitchAPI.twitch.Twitch.get_soundtrack_playlist()` - :const:`~twitchAPI.twitch.Twitch.get_soundtrack_playlists()` PubSub ------ - PubSub now cleanly reestablishes the connection when the websocket was unexpectedly closed **************** Version 3.9.0 **************** Twitch ------ - Added the following new Endpoints: - "Get Channel Followers" :const:`~twitchAPI.twitch.Twitch.get_channel_followers()` - "Get Followed Channels" :const:`~twitchAPI.twitch.Twitch.get_followed_channels()` - Fixed TypeError: __api_get_request() got an unexpected keyword argument 'body' (Thanks https://github.com/JC-Chung ) EventSub -------- - Added new Topic :const:`~twitchAPI.eventsub.EventSub.listen_channel_follow_v2()` Chat ---- - Bot is now correctly reconnecting and rejoining channels after losing connection - added :const:`~twitchAPI.chat.Chat.is_subscriber()` (Thanks https://github.com/stolenvw ) - added new Event :const:`~twitchAPI.types.ChatEvent.NOTICE` - Triggered when server sends a notice message (Thanks https://github.com/stolenvw ) **************** Version 3.8.0 **************** Twitch ------ - Added the new Endpoint "Send a Shoutout" :const:`~twitchAPI.twitch.Twitch.send_a_shoutout()` - :const:`~twitchAPI.twitch.Twitch.get_users_follows()` is now marked as deprecated - Added missing parameter :code:`type` to :const:`~twitchAPI.twitch.Twitch.get_streams()` Helper ------ - Added new Async Generator helper :const:`~twitchAPI.helper.limit()`, with this you can limit the amount of results returned from the given AsyncGenerator to a maximum number EventSub -------- - Added the following new Topics: - "Channel Shoutout Create" :const:`~twitchAPI.eventsub.EventSub.listen_channel_shoutout_create()` - "Channel Shoutout Receive" :const:`~twitchAPI.eventsub.EventSub.listen_channel_shoutout_receive()` PubSub ------ - Added new Topic "Low trust Users" :const:`~twitchAPI.pubsub.PubSub.listen_low_trust_users()` Chat ---- - Improved rate limit handling of :const:`~twitchAPI.chat.Chat.join_room()` when joining multiple rooms per call - The following functions now all ignore the capitalization of the given chat room: - :const:`~twitchAPI.chat.Chat.join_room()` - :const:`~twitchAPI.chat.Chat.leave_room()` - :const:`~twitchAPI.chat.Chat.is_mod()` - :const:`~twitchAPI.chat.Chat.send_message()` - Added :const:`initial_channel` to :const:`~twitchAPI.chat.Chat.__init__()`, with this you can auto join channels on bot startup - Added :const:`~twitchAPI.chat.Chat.is_in_room()` - Added :const:`~twitchAPI.chat.Chat.log_no_registered_command_handler`, with this you can control if the "no registered handler for event" warnings should be logged or not OAuth ----- - Added the following new AuthScopes: - :const:`~twitchAPI.types.AuthScope.MODERATOR_MANAGE_SHOUTOUTS` - :const:`~twitchAPI.types.AuthScope.MODERATOR_READ_SHOUTOUTS` - :const:`~twitchAPI.types.AuthScope.MODERATOR_READ_FOLLOWERS` - Improved async handling of :const:`~twitchAPI.oauth.UserAuthenticator` **************** Version 3.7.0 **************** Twitch ------ - Added the following Endpoints: - "Get AutoMod Settings" :const:`~twitchAPI.twitch.Twitch.get_automod_settings()` - "Update AutoMod Settings" :const:`~twitchAPI.twitch.Twitch.update_automod_settings()` - Added :const:`~twitchAPI.twitch.Twitch.session_timeout` config. With this you can optionally change the timeout behavior across the entire library OAuth ----- - Added the following new AuthScopes: - :const:`~twitchAPI.types.AuthScope.MODERATOR_READ_AUTOMOD_SETTINGS` - :const:`~twitchAPI.types.AuthScope.MODERATOR_MANAGE_AUTOMOD_SETTINGS` **************** Version 3.6.2 **************** - Added :code:`py.typed` file to comply with PEP-561 Twitch ------ - Fixed all Endpoints that use :const:`~twitchAPI.object.AsyncIterTwitchObject` yielding some items multiple times - added missing field :const:`~twitchAPI.object.TwitchUserFollow.to_login` to :const:`~twitchAPI.twitch.Twitch.get_users_follows()` **************** Version 3.6.1 **************** EventSub -------- - :const:`~twitchAPI.eventsub.EventSub.start()` now waits till the internal web server has fully started up Chat ---- - Added :const:`~twitchAPI.chat.Chat.is_mod()` function (Thanks https://github.com/stolenvw ) - Made the check if the bot is a moderator in the current channel for message sending rate limiting more consistent (Thanks https://github.com/stolenvw ) **************** Version 3.5.2 **************** Twitch ------ - Fixed :const:`~twitchAPI.twitch.Twitch.end_prediction()` calling NoneType **************** Version 3.5.1 **************** Chat ---- - Fixed KeyError in clear chat event **************** Version 3.5.0 **************** Twitch ------ - Added the following new Endpoints: - "Get Charity Campaign" :const:`~twitchAPI.twitch.Twitch.get_charity_campaign()` - "Get Charity Donations" :const:`~twitchAPI.twitch.Twitch.get_charity_donations()` - Fixed bug that made the user refresh token invalid in some rare edge cases EventSub -------- - Added the following new Topics: - "Charity Campaign Start" :const:`~twitchAPI.eventsub.EventSub.listen_channel_charity_campaign_start()` - "Charity Campaign Stop" :const:`~twitchAPI.eventsub.EventSub.listen_channel_charity_campaign_stop()` - "Charity Campaign Progress" :const:`~twitchAPI.eventsub.EventSub.listen_channel_charity_campaign_progress()` - "Charity Campaign Donate" :const:`~twitchAPI.eventsub.EventSub.listen_channel_charity_campaign_donate()` PubSub ------ - Added :const:`~twitchAPI.pubsub.PubSub.is_connected()` - Fixed bug that prevented a clean shutdown on Linux Chat ---- - Added automatic rate limit handling to channel joining and message sending - :const:`~twitchAPI.chat.Chat.send_message()` now waits till reconnected when Chat got disconnected - :const:`~twitchAPI.chat.Chat.send_raw_irc_message()` now waits till reconnected when Chat got disconnected - Added :const:`~twitchAPI.chat.Chat.is_connected()` - Added :const:`~twitchAPI.chat.Chat.is_ready()` - Chat now cleanly handles reconnect requests OAuth ----- - Added new Auth Scope :const:`~twitchAPI.types.AuthScope.CHANNEL_READ_CHARITY` - Fixed bug that prevented a clean shutdown on Linux **************** Version 3.4.1 **************** - fixed bug that prevented newer pip versions from gathering the dependencies **************** Version 3.4.0 **************** Twitch ------ - Added the following new Endpoints: - "Update Shield Mode Status" :const:`~twitchAPI.twitch.Twitch.update_shield_mode_status()` - "Get Shield Mode Status" :const:`~twitchAPI.twitch.Twitch.get_shield_mode_status()` - Added the new :code:`tags` Field to the following Endpoints: - "Get Streams" :const:`~twitchAPI.twitch.Twitch.get_streams()` - "Get Followed Streams" :const:`~twitchAPI.twitch.Twitch.get_followed_streams()` - "Search Channels" :const:`~twitchAPI.twitch.Twitch.search_channels()` - "Get Channel Information" :const:`~twitchAPI.twitch.Twitch.get_channel_information()` - "Modify Channel Information" :const:`~twitchAPI.twitch.Twitch.modify_channel_information()` - Improved documentation EventSub -------- - Added the following new Topics: - "Shield Mode End" :const:`~twitchAPI.eventsub.EventSub.listen_channel_shield_mode_end()` - "Shield Mode Begin" :const:`~twitchAPI.eventsub.EventSub.listen_channel_shield_mode_begin()` - Improved type hints of :code:`listen_` functions - Added check if given callback is a coroutine to :code:`listen_` functions PubSub ------ - Fixed AttributeError when reconnecting Chat ---- - Expanded documentation on Events and Commands - Fixed room cache being randomly destroyed over time - Improved message handling performance drastically for high volume chat bots - Fixed AttributeError when reconnecting - :const:`~twitchAPI.chat.Chat.join_room()` now times out when it was unable to join a room instead of being infinitly stuck - :const:`~twitchAPI.chat.Chat.join_room()` now returns a list of channels it was unable to join - Added :const:`~twitchAPI.chat.Chat.join_timeout` - Added :const:`~twitchAPI.chat.Chat.unregister_command()` - Added :const:`~twitchAPI.chat.Chat.unregister_event()` - Added the following new Events: - :const:`~twitchAPI.types.ChatEvent.USER_LEFT` - Triggered when a user leaves a chat channel - :const:`~twitchAPI.types.ChatEvent.CHAT_CLEARED` - Triggered when a user was timed out, banned or the messages where deleted - :const:`~twitchAPI.types.ChatEvent.WHISPER` - Triggered when a user sends a whisper message to the bot OAuth ----- - fixed :const:`~twitchAPI.oauth.UserAuthenticator.authenticate()` getting stuck when :code:`user_token` is provided (thanks https://github.com/Tempystral ) **************** Version 3.3.0 **************** - Added new event to Chat: :const:`~twitchAPI.types.ChatEvent.MESSAGE_DELETE` which triggers whenever a single message got deleted in a channel - Added :const:`~twitchAPI.chat.Chat.send_raw_irc_message()` method for sending raw irc commands to the websocket. Use with care! - Fixed missing state cleanup after closing Chat, preventing the same instance from being started again - fixed :const:`~twitchAPI.types.ChatRoom.room_id` always being Null **************** Version 3.2.2 **************** - Fixed return type of :const:`~twitchAPI.twitch.Twitch.get_broadcaster_subscriptions()` - removed any field starting with underscore from :const:`~twitchAPI.object.TwitchObject.to_dict()` **************** Version 3.2.1 **************** - Fixed bug that resulted in a timeout when reading big API requests - Optimized the use of Sessions, slight to decent performance optimization for API requests, especially for async generators **************** Version 3.2.0 **************** - Made the used loggers available for easy logging configuration - added the option to set the chat command prefix via :const:`~twitchAPI.chat.Chat.set_prefix()` - :const:`~twitchAPI.twitch.Twitch.set_user_authentication()` now also throws a :const:`~twitchAPI.types.MissingScopeException` when no scope is given. (thanks https://github.com/aw-was-here ) **************** Version 3.1.1 **************** - Added the Endpoint "Get Chatters" :const:`~twitchAPI.twitch.Twitch.get_chatters()` - Added the :const:`~twitchAPI.types.AuthScope.MODERATOR_READ_CHATTERS` AuthScope - Added missing :const:`total` field to :const:`~twitchAPI.twitch.Twitch.get_users_follows()` - added :const:`~twitchAPI.chat.ChatCommand.send()` shorthand to ChatCommand, this makes sending command replies easier. - Fixed issue which prevented the Twitch client being used inside a EventSub, PubSub or Chat callback - Fixed issue with using the wrong API url in :const:`~twitchAPI.twitch.Twitch.create_custom_reward()` - :const:`twitchAPI.helper.first()` now returns None when there is no data to return instead of raising StopAsyncIteration exception - Exceptions in Chat callback methods are now properly displayed **************** Version 3.0.1 **************** - Fixed bug which resulted in :code:`Timeout context manager should be used inside a task` when subscribing to more than one EventSub topic **************** Version 3.0.0 **************** .. note:: This Version is a major rework of the library. Please see the :doc:`v3-migration` to learn how to migrate. **Highlights** - Library is now fully async - Twitch API responses are now Objects and Generators instead of pure dictionaries - Automatic Pagination of API results - First alpha of a Chat Bot implementation - More customizability for the UserAuthenticator - A lot of new Endpoints where added - New look and content for the documentation **Full Changelog** * Rewrote the twitchAPI to be async * twitchAPI now uses Objects instead of dictionaries * added automatic pagination to all relevant API endpoints * PubSub now uses async callbacks * EventSub subscribing and unsubscribing is now async * Added a alpha version of a Twitch Chat Bot implementation * switched AuthScope `CHANNEL_MANAGE_CHAT_SETTINGS` to `MODERATOR_MANAGE_CHAT_SETTINGS` * Added the following AuthScopes: * :const:`~twitchAPI.types.AuthScope.MODERATOR_MANAGE_ANNOUNCEMENTS` * :const:`~twitchAPI.types.AuthScope.MODERATOR_MANAGE_CHAT_MESSAGES` * :const:`~twitchAPI.types.AuthScope.USER_MANAGE_CHAT_COLOR` * :const:`~twitchAPI.types.AuthScope.CHANNEL_MANAGE_MODERATORS` * :const:`~twitchAPI.types.AuthScope.CHANNEL_READ_VIPS` * :const:`~twitchAPI.types.AuthScope.CHANNEL_MANAGE_VIPS` * :const:`~twitchAPI.types.AuthScope.USER_MANAGE_WHISPERS` * added :const:`~twitchAPI.helper.first()` helper function * Added the following new Endpoints: * "Send Whisper" :const:`~twitchAPI.twitch.Twitch.send_whisper()` * "Remove Channel VIP" :const:`~twitchAPI.twitch.Twitch.remove_channel_vip()` * "Add Channel VIP" :const:`~twitchAPI.twitch.Twitch.add_channel_vip()` * "Get VIPs" :const:`~twitchAPI.twitch.Twitch.get_vips()` * "Add Channel Moderator" :const:`~twitchAPI.twitch.Twitch.add_channel_moderator()` * "Remove Channel Moderator" :const:`~twitchAPI.twitch.Twitch.remove_channel_moderator()` * "Get User Chat Color" :const:`~twitchAPI.twitch.Twitch.get_user_chat_color()` * "Update User Chat Color" :const:`~twitchAPI.twitch.Twitch.update_user_chat_color()` * "Delete Chat Message" :const:`~twitchAPI.twitch.Twitch.delete_chat_message()` * "Send Chat Announcement" :const:`~twitchAPI.twitch.Twitch.send_chat_announcement()` * "Get Soundtrack Current Track" :const:`~twitchAPI.twitch.Twitch.get_soundtrack_current_track()` * "Get Soundtrack Playlist" :const:`~twitchAPI.twitch.Twitch.get_soundtrack_playlist()` * "Get Soundtrack Playlists" :const:`~twitchAPI.twitch.Twitch.get_soundtrack_playlists()` * Removed the folllowing deprecated Endpoints: * "Get Banned Event" * "Get Moderator Events" * "Get Webhook Subscriptions" * The following Endpoints got changed: * Added `igdb_id` search parameter to :const:`~twitchAPI.twitch.Twitch.get_games()` * Removed the Voting related fields in :const:`~twitchAPI.twitch.Twitch.create_poll()` due to being deprecated * Updated the logic in :const:`~twitchAPI.twitch.Twitch.update_custom_reward()` to avoid API errors * Removed `id` parameter from :const:`~twitchAPI.twitch.Twitch.get_hype_train_events()` * Fixed the range check in :const:`~twitchAPI.twitch.Twitch.get_channel_information()` * :const:`~twitchAPI.twitch.Twitch.app_auth_refresh_callback` and :const:`~twitchAPI.twitch.Twitch.user_auth_refresh_callback` are now async * Added :const:`~twitchAPI.oauth.get_user_info()` * UserAuthenticator: * You can now set the document that will be shown at the end of the Auth flow by setting :const:`~twitchAPI.oauth.UserAuthenticator.document` * The optional callback is now called with the access and refresh token instead of the user token * Added browser controls to :const:`~twitchAPI.oauth.UserAuthenticator.authenticate()` * removed :code:`requests` and :code:`websockets` libraries from the requirements (resulting in smaller library footprint) **************** Version 2.5.7 **************** - Fixed the End Poll Endpoint - Properly define terminated poll status (thanks @iProdigy!) **************** Version 2.5.6 **************** - Updated Create Prediction to take between 2 and 10 outcomes (thanks @lynara!) - Added "Get Creator Goals" Endpoint (thanks @gitagogaming!) - TwitchAPIException will now also include the message from the Twitch API when available **************** Version 2.5.5 **************** - Added datetime parsing to `created_at` field for Ban User and Get Banned Users endpoints - fixed title length check failing if the title is None for Modify Channel Information endpoint (thanks @Meduris!) **************** Version 2.5.4 **************** - Added the following new endpoints: - "Ban User" - "Unban User" - "Get Blocked Terms" - "Add Blocked Term" - "Remove Blocked Term" - Added the following Auth Scopes: - `moderator:manage:banned_users` - `moderator:read:blocked_terms` - `moderator:manage:blocked_terms` - Added additional debug logging to PubSub - Fixed KeyError when being rate limited **************** Version 2.5.3 **************** - `Twitch.get_channel_info` now also optionally accepts a list of strings with up to 100 entries for the `broadcaster_id` parameter **************** Version 2.5.2 **************** - Added the following new endpoints: - "Get Chat Settings" - "Update Chat Settings" - Added Auth Scope "channel:manage:chat_settings" - Fixed error in Auth Scope "channel:manage:schedule" - Fixed error in Endpoint "Get Extension Transactions" - Removed unusable Webhook code **************** Version 2.5.1 **************** - Fixed bug that prevented EventSub subscriptions to work if main threads asyncio loop was already running **************** Version 2.5.0 **************** - EventSub and PubSub callbacks are now executed non blocking, this fixes that long running callbacks stop the library to respond to heartbeats etc. - EventSub subscription can now throw a TwitchBackendException when the API returns a Error 500 - added the following EventSub topics (thanks d7415!) - "Goal Begin" - "Goal Progress" - "Goal End" **************** Version 2.4.2 **************** - Fixed EventSub not keeping local state in sync on unsubscribe - Added proper exception if authentication via oauth fails **************** Version 2.4.1 **************** - EventSub now uses a random 20 letter secret by default - EventSub now verifies the send signature **************** Version 2.4.0 **************** - **Implemented EventSub** - Marked Webhook as deprecated - added the following new endpoints - "Get Followed Streams" - "Get Polls" - "End Poll" - "Get Predictions" - "Create Prediction" - "End Prediction" - "Manage held AutoMod Messages" - "Get Channel Badges" - "Get Global Chat Badges" - "Get Channel Emotes" - "Get Global Emotes" - "Get Emote Sets" - "Delete EventSub Subscription" - "Get Channel Stream Schedule" - "Get Channel iCalendar" - "Update Channel Stream Schedule" - "Create Channel Stream Schedule Segment" - "Update Channel Stream Schedule Segment" - "Delete Channel Stream Schedule Segment" - "Update Drops Entitlements" - Added the following new AuthScopes - USER_READ_FOLLOWS - CHANNEL_READ_POLLS - CHANNEL_MANAGE_POLLS - CHANNEL_READ_PREDICTIONS - CHANNEL_MANAGE_PREDICTIONS - MODERATOR_MANAGE_AUTOMOD - CHANNEL_MANAGE_SCHEDULE - removed deprecated Endpoints - "Create User Follows" - "Delete User Follows" - Added Topics to PubSub - "AutoMod Queue" - "User Moderation Notifications" - Check if at least one of status or id is provided in get_custom_reward_redemption - reverted change that made reward_id optional in get_custom_reward_redemption - get_extension_transactions now takes up to 100 transaction ids - added delay parameter to modify_channel_information - made parameter prompt of create_custom_reward optional and changed parameter order - made reward_id of get_custom_reward take either a list of str or str - made parameter title, prompt and cost optional in update_custom_reward - made parameter redemption_ids of update_redemption_status take either a list of str or str - fixed exception in block_user - allowed Twitch.check_automod_status to take in more that one entry **************** Version 2.3.2 **************** * fixed get_custom_reward_redemption url (thanks iProdigy!) * made reward_id parameter of get_custom_reward_redemption optional **************** Version 2.3.1 **************** * fixed id parameter for get_clips of Twitch **************** Version 2.3.0 **************** * Initializing the Twitch API now automatically creates a app authorization (can be disabled via flag) * Made it possible to not set a app secret in cases where only user authentication is required * added helper function `validate_token` to OAuth * added helper function `revoke_token` to OAuth * User OAuth Token is now automatically validated for correct scope and validity when being set * added new "Get Drops Entitlement" endpoint * added new "Get Teams" endpoint * added new "Get Chattel teams" endpoint * added new AuthScope USER_READ_SUBSCRIPTIONS * fixed exception in Webhook if no Authentication is set and also not required * refactored Authentication handling, making it more versatile * added more debugging logs * improved documentation **************** Version 2.2.5 **************** * added optional callback to Twitch for user and app access token refresh * added additional check for non empty title in Twitch.modify_channel_information * changed required scope of PubSub.listen_channel_subscriptions from CHANNEL_SUBSCRIPTIONS to CHANNEL_READ_SUBSCRIPTIONS **************** Version 2.2.4 **************** * added Python 3.9 compatibility * improved example for PubSub **************** Version 2.2.3 **************** * added new "get channel editors" endpoint * added new "delete videos" endpoint * added new "get user block list" endpoint * added new "block user" endpoint * added new "unblock user" endpoint * added new authentication scopes * some refactoring **************** Version 2.2.2 **************** * added missing API base url to delete_custom_reward, get_custom_reward, get_custom_reward_redemption and update_redemption_status (thanks asphaltschneider!) **************** Version 2.2.1 **************** * added option to set a ssl context to be used by Webhook * fixed modify_channel_information throwing ValueError (thanks asishm!) * added default route to Webhook on / for easier debugging * properly check for empty lists in the selection of the used AuthScope in get_users * raise ValueError if both from_id and to_id are None in subscribe_user_follow of Webhook **************** Version 2.2.0 **************** * added missing "Create custom rewards" endpoint * added missing "Delete Custom rewards" endpoint * added missing "Get Custom Reward" endpoint * added missing "Get custom reward redemption" endpoint * added missing "Update custom Reward" endpoint * added missing "Update redemption status" endpoint * added missing pagination parameters to endpoints that support them * improved documentation * properly handle 401 response after retries **************** Version 2.1 **************** Added a Twitch PubSub client implementation. See :doc:`modules/twitchAPI.pubsub` for more Info! * added PubSub client * made UserAuthenticator URL dynamic * added named loggers for all modules * fixed bug in Webhook.subscribe_subscription_events * added Twitch.get_user_auth_scope **************** Version 2.0.1 **************** Fixed some bugs and implemented changes made to the Twitch API **************** Version 2.0 **************** This version is a major overhaul of the Webhook, implementing missing and changed API endpoints and adding a bunch of quality of life changes. * Reworked the entire Documentation * Webhook subscribe and unsubscribe now waits for handshake to finish * Webhook now refreshes its subscriptions * Webhook unsubscribe is now a single function * Webhook auto unsubscribes from topics on stop() * Added unsubscribe_all function to Webhook * Twitch instance now auto renews auth token once they become invalid * Added retry on API backend error * Added get_drops_entitlements endpoint * Fixed function signature of get_webhook_subscriptions * Fixed update_user_extension not writing data * get_user_active_extensions now requires User Authentication * get_user_follows now requires at elast App Authentication * get_users now follows the changed API Authentication logic * get_stream_markers now also checks that at least one of user_id or video_id is provided * get_streams now takes a list for game_id * get_streams now checks the length of the language list * get_moderator_events now takes in a list of user_ids * get_moderators now takes in a list of user_ids * get_clips can now use the first parameter * Raise exception when twitch backend returns 503 even after a retry * Now use custom exception classes * Removed depraced endpoint get_streams_metadata Teekeks-pyTwitchAPI-0d97664/docs/conf.py000066400000000000000000000077341463733066200200110ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys import aiohttp sys.path.insert(0, os.path.abspath('..')) # -- Project information ----------------------------------------------------- project = 'twitchAPI' copyright = '2023, Lena "Teekeks" During' author = 'Lena "Teekeks" During' # The full version, including alpha/beta/rc tags release = None with open('../twitchAPI/__init__.py') as f: for line in f.readlines(): if line.startswith('__version__'): release = 'v' + line.split('= \'')[-1][:-2].strip() if release is None: release = 'dev' language = 'en' master_doc = 'index' # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx_autodoc_typehints', 'sphinx.ext.intersphinx', 'sphinx.ext.autosummary', 'enum_tools.autoenum', 'recommonmark', 'sphinx_paramlinks' ] aiohttp.client.ClientTimeout.__module__ = 'aiohttp' aiohttp.ClientTimeout.__module__ = 'aiohttp' autodoc_member_order = 'bysource' autodoc_class_signature = 'separated' # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] intersphinx_mapping = { 'python': ('https://docs.python.org/3', None), 'aio': ('https://docs.aiohttp.org/en/stable/', None) } rst_epilog = """ .. |default| raw:: html
Default: .. |br| raw:: html
""" def setup(app): app.add_css_file('css/custom.css') html_theme = 'pydata_sphinx_theme' # Define the json_url for our version switcher. json_url = "/en/latest/_static/switcher.json" # Define the version we use for matching in the version switcher. version_match = os.environ.get("READTHEDOCS_VERSION") # If READTHEDOCS_VERSION doesn't exist, we're not on RTD # If it is an integer, we're in a PR build and the version isn't correct. if not version_match or version_match.isdigit(): # For local development, infer the version to match from the package. # release = release if "-a" in release or "-b" in release or "rc" in release: version_match = "develop" # We want to keep the relative reference if we are in dev mode # but we want the whole url if we are effectively in a released version json_url = "/en/latest/_static/switcher.json" else: version_match = release html_theme_options = { "switcher": { "json_url": json_url, "version_match": version_match, }, "header_links_before_dropdown": 4, "navbar_center": ["version-switcher", "navbar-nav"], "github_url": "https://github.com/Teekeks/pyTwitchAPI", "pygment_dark_style": "monokai", "navbar_align": "left", "logo": { "text": "twitchAPI", "image_light": "logo.png", "image_dark": "logo.png" } } # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] Teekeks-pyTwitchAPI-0d97664/docs/index.rst000066400000000000000000000262641463733066200203520ustar00rootroot00000000000000.. twitchAPI documentation master file, created by sphinx-quickstart on Sat Mar 28 12:49:23 2020. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Python Twitch API ================= This is a full implementation of the Twitch Helix API, PubSub, EventSub and Chat in python 3.7+. On Github: https://github.com/Teekeks/pyTwitchAPI On PyPi: https://pypi.org/project/twitchAPI/ Changelog: :doc:`changelog` Tutorials: :doc:`tutorials` .. note:: There were major changes to the library with version 4, see the :doc:`v4-migration` to learn how to migrate. Installation ============ Install using pip: ``pip install twitchAPI`` Support ======= For Support please join the `Twitch API Discord server `_. Usage ===== These are some basic usage examples, please visit the dedicated pages for more info. TwitchAPI --------- Calls to the Twitch Helix API, this is the base of this library. See here for more info: :doc:`/modules/twitchAPI.twitch` .. code-block:: python from twitchAPI.twitch import Twitch from twitchAPI.helper import first import asyncio async def twitch_example(): # initialize the twitch instance, this will by default also create a app authentication for you twitch = await Twitch('app_id', 'app_secret') # call the API for the data of your twitch user # this returns a async generator that can be used to iterate over all results # but we are just interested in the first result # using the first helper makes this easy. user = await first(twitch.get_users(logins='your_twitch_user')) # print the ID of your user or do whatever else you want with it print(user.id) # run this example asyncio.run(twitch_example()) Authentication -------------- The Twitch API knows 2 different authentications. App and User Authentication. Which one you need (or if one at all) depends on what calls you want to use. 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. See here for more info about user authentication: :doc:`/modules/twitchAPI.oauth` App Authentication ^^^^^^^^^^^^^^^^^^ App authentication is super simple, just do the following: .. code-block:: python from twitchAPI.twitch import Twitch twitch = await Twitch('my_app_id', 'my_app_secret') User Authentication ^^^^^^^^^^^^^^^^^^^ 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. For my Authenticator you have to add the following URL as a "OAuth Redirect URL": :code:`http://localhost:17563` You can set that `here in your twitch dev dashboard `_. .. code-block:: python from twitchAPI.twitch import Twitch from twitchAPI.oauth import UserAuthenticator from twitchAPI.type import AuthScope twitch = await Twitch('my_app_id', 'my_app_secret') target_scope = [AuthScope.BITS_READ] auth = UserAuthenticator(twitch, target_scope, force_verify=False) # this will open your default browser and prompt you with the twitch verification website token, refresh_token = await auth.authenticate() # add User authentication await twitch.set_user_authentication(token, target_scope, refresh_token) You can reuse this token and use the refresh_token to renew it: .. code-block:: python from twitchAPI.oauth import refresh_access_token new_token, new_refresh_token = await refresh_access_token('refresh_token', 'client_id', 'client_secret') AuthToken refresh callback ^^^^^^^^^^^^^^^^^^^^^^^^^^ Optionally you can set a callback for both user access token refresh and app access token refresh. .. code-block:: python from twitchAPI.twitch import Twitch async def user_refresh(token: str, refresh_token: str): print(f'my new user token is: {token}') async def app_refresh(token: str): print(f'my new app token is: {token}') twitch = await Twitch('my_app_id', 'my_app_secret') twitch.app_auth_refresh_callback = app_refresh twitch.user_auth_refresh_callback = user_refresh EventSub -------- EventSub lets you listen for events that happen on Twitch. There are multiple EventSub transports available, used for different use cases. See here for more info about EventSub in general and the different Transports, including code examples: :doc:`/modules/twitchAPI.eventsub` PubSub ------ PubSub enables you to subscribe to a topic, for updates (e.g., when a user cheers in a channel). See here for more info: :doc:`/modules/twitchAPI.pubsub` .. code-block:: python from twitchAPI.pubsub import PubSub from twitchAPI.twitch import Twitch from twitchAPI.helper import first from twitchAPI.type import AuthScope from twitchAPI.oauth import UserAuthenticator import asyncio from pprint import pprint from uuid import UUID APP_ID = 'my_app_id' APP_SECRET = 'my_app_secret' USER_SCOPE = [AuthScope.WHISPERS_READ] TARGET_CHANNEL = 'teekeks42' async def callback_whisper(uuid: UUID, data: dict) -> None: print('got callback for UUID ' + str(uuid)) pprint(data) async def run_example(): # setting up Authentication and getting your user id twitch = await Twitch(APP_ID, APP_SECRET) auth = UserAuthenticator(twitch, [AuthScope.WHISPERS_READ], force_verify=False) token, refresh_token = await auth.authenticate() # you can get your user auth token and user auth refresh token following the example in twitchAPI.oauth await twitch.set_user_authentication(token, [AuthScope.WHISPERS_READ], refresh_token) user = await first(twitch.get_users(logins=[TARGET_CHANNEL])) # starting up PubSub pubsub = PubSub(twitch) pubsub.start() # you can either start listening before or after you started pubsub. uuid = await pubsub.listen_whispers(user.id, callback_whisper) input('press ENTER to close...') # you do not need to unlisten to topics before stopping but you can listen and unlisten at any moment you want await pubsub.unlisten(uuid) pubsub.stop() await twitch.close() asyncio.run(run_example()) Chat ---- A simple twitch chat bot. Chat bots can join channels, listen to chat and reply to messages, commands, subscriptions and many more. See here for more info: :doc:`/modules/twitchAPI.chat` .. code-block:: python from twitchAPI.twitch import Twitch from twitchAPI.oauth import UserAuthenticator from twitchAPI.type import AuthScope, ChatEvent from twitchAPI.chat import Chat, EventData, ChatMessage, ChatSub, ChatCommand import asyncio APP_ID = 'my_app_id' APP_SECRET = 'my_app_secret' USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] TARGET_CHANNEL = 'teekeks42' # this will be called when the event READY is triggered, which will be on bot start async def on_ready(ready_event: EventData): print('Bot is ready for work, joining channels') # join our target channel, if you want to join multiple, either call join for each individually # or even better pass a list of channels as the argument await ready_event.chat.join_room(TARGET_CHANNEL) # you can do other bot initialization things in here # this will be called whenever a message in a channel was send by either the bot OR another user async def on_message(msg: ChatMessage): print(f'in {msg.room.name}, {msg.user.name} said: {msg.text}') # this will be called whenever someone subscribes to a channel async def on_sub(sub: ChatSub): print(f'New subscription in {sub.room.name}:\\n' f' Type: {sub.sub_plan}\\n' f' Message: {sub.sub_message}') # this will be called whenever the !reply command is issued async def test_command(cmd: ChatCommand): if len(cmd.parameter) == 0: await cmd.reply('you did not tell me what to reply with') else: await cmd.reply(f'{cmd.user.name}: {cmd.parameter}') # this is where we set up the bot async def run(): # set up twitch api instance and add user authentication with some scopes twitch = await Twitch(APP_ID, APP_SECRET) auth = UserAuthenticator(twitch, USER_SCOPE) token, refresh_token = await auth.authenticate() await twitch.set_user_authentication(token, USER_SCOPE, refresh_token) # create chat instance chat = await Chat(twitch) # register the handlers for the events you want # listen to when the bot is done starting up and ready to join channels chat.register_event(ChatEvent.READY, on_ready) # listen to chat messages chat.register_event(ChatEvent.MESSAGE, on_message) # listen to channel subscriptions chat.register_event(ChatEvent.SUB, on_sub) # there are more events, you can view them all in this documentation # you can directly register commands and their handlers, this will register the !reply command chat.register_command('reply', test_command) # we are done with our setup, lets start this bot up! chat.start() # lets run till we press enter in the console try: input('press ENTER to stop\\n') finally: # now we can close the chat bot and the twitch api client chat.stop() await twitch.close() # lets run our setup asyncio.run(run()) Logging ======= This module uses the `logging` module for creating Logs. Valid loggers are: .. list-table:: :header-rows: 1 * - Logger Name - Class - Variable * - :code:`twitchAPI.twitch` - :const:`~twitchAPI.twitch.Twitch` - :const:`~twitchAPI.twitch.Twitch.logger` * - :code:`twitchAPI.chat` - :const:`~twitchAPI.chat.Chat` - :const:`~twitchAPI.chat.Chat.logger` * - :code:`twitchAPI.eventsub.webhook` - :const:`~twitchAPI.eventsub.webhook.EventSubWebhook` - :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.logger` * - :code:`twitchAPI.eventsub.websocket` - :const:`~twitchAPI.eventsub.websocket.EventSubWebsocket` - :const:`~twitchAPI.eventsub.websocket.EventSubWebsocket.logger` * - :code:`twitchAPI.pubsub` - :const:`~twitchAPI.pubsub.PubSub` - :const:`~twitchAPI.pubsub.PubSub.logger` * - :code:`twitchAPI.oauth` - :const:`~twitchAPI.oauth.UserAuthenticator` - :const:`~twitchAPI.oauth.UserAuthenticator.logger` * - :code:`twitchAPI.oauth.storage_helper` - :const:`~twitchAPI.oauth.UserAuthenticationStorageHelper` - :const:`~twitchAPI.oauth.UserAuthenticationStorageHelper.logger` Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :doc:`tutorials` * :doc:`changelog` * :doc:`v3-migration` * :doc:`v4-migration` .. autosummary:: :toctree: modules twitchAPI.twitch twitchAPI.eventsub twitchAPI.pubsub twitchAPI.chat twitchAPI.chat.middleware twitchAPI.oauth twitchAPI.type twitchAPI.helper twitchAPI.object .. toctree:: :maxdepth: 2 :caption: Contents: :hidden: tutorials changelog Teekeks-pyTwitchAPI-0d97664/docs/make.bat000066400000000000000000000013701463733066200201050ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd Teekeks-pyTwitchAPI-0d97664/docs/modules/000077500000000000000000000000001463733066200201475ustar00rootroot00000000000000Teekeks-pyTwitchAPI-0d97664/docs/modules/twitchAPI.chat.middleware.rst000066400000000000000000000001761463733066200255730ustar00rootroot00000000000000 .. automodule:: twitchAPI.chat.middleware :members: :undoc-members: :show-inheritance: :inherited-members:Teekeks-pyTwitchAPI-0d97664/docs/modules/twitchAPI.chat.rst000066400000000000000000000001631463733066200234530ustar00rootroot00000000000000 .. automodule:: twitchAPI.chat :members: :undoc-members: :show-inheritance: :inherited-members:Teekeks-pyTwitchAPI-0d97664/docs/modules/twitchAPI.eventsub.base.rst000066400000000000000000000002051463733066200252750ustar00rootroot00000000000000:orphan: .. automodule:: twitchAPI.eventsub.base :members: :undoc-members: :show-inheritance: :inherited-members: Teekeks-pyTwitchAPI-0d97664/docs/modules/twitchAPI.eventsub.rst000066400000000000000000000001671463733066200243730ustar00rootroot00000000000000 .. automodule:: twitchAPI.eventsub :members: :undoc-members: :show-inheritance: :inherited-members:Teekeks-pyTwitchAPI-0d97664/docs/modules/twitchAPI.eventsub.webhook.rst000066400000000000000000000002101463733066200260150ustar00rootroot00000000000000:orphan: .. automodule:: twitchAPI.eventsub.webhook :members: :undoc-members: :show-inheritance: :inherited-members: Teekeks-pyTwitchAPI-0d97664/docs/modules/twitchAPI.eventsub.websocket.rst000066400000000000000000000002121463733066200263470ustar00rootroot00000000000000:orphan: .. automodule:: twitchAPI.eventsub.websocket :members: :undoc-members: :show-inheritance: :inherited-members: Teekeks-pyTwitchAPI-0d97664/docs/modules/twitchAPI.helper.rst000066400000000000000000000001651463733066200240150ustar00rootroot00000000000000 .. automodule:: twitchAPI.helper :members: :undoc-members: :show-inheritance: :inherited-members:Teekeks-pyTwitchAPI-0d97664/docs/modules/twitchAPI.oauth.rst000066400000000000000000000001641463733066200236550ustar00rootroot00000000000000 .. automodule:: twitchAPI.oauth :members: :undoc-members: :show-inheritance: :inherited-members:Teekeks-pyTwitchAPI-0d97664/docs/modules/twitchAPI.object.api.rst000066400000000000000000000001721463733066200245520ustar00rootroot00000000000000 .. automodule:: twitchAPI.object.api :members: :undoc-members: :show-inheritance: :inherited-members: Teekeks-pyTwitchAPI-0d97664/docs/modules/twitchAPI.object.base.rst000066400000000000000000000001731463733066200247140ustar00rootroot00000000000000 .. automodule:: twitchAPI.object.base :members: :undoc-members: :show-inheritance: :inherited-members: Teekeks-pyTwitchAPI-0d97664/docs/modules/twitchAPI.object.eventsub.rst000066400000000000000000000001771463733066200256410ustar00rootroot00000000000000 .. automodule:: twitchAPI.object.eventsub :members: :undoc-members: :show-inheritance: :inherited-members: Teekeks-pyTwitchAPI-0d97664/docs/modules/twitchAPI.object.rst000066400000000000000000000001651463733066200240040ustar00rootroot00000000000000 .. automodule:: twitchAPI.object :members: :undoc-members: :show-inheritance: :inherited-members:Teekeks-pyTwitchAPI-0d97664/docs/modules/twitchAPI.pubsub.rst000066400000000000000000000001651463733066200240360ustar00rootroot00000000000000 .. automodule:: twitchAPI.pubsub :members: :undoc-members: :show-inheritance: :inherited-members:Teekeks-pyTwitchAPI-0d97664/docs/modules/twitchAPI.twitch.rst000066400000000000000000000001651463733066200240400ustar00rootroot00000000000000 .. automodule:: twitchAPI.twitch :members: :undoc-members: :show-inheritance: :inherited-members:Teekeks-pyTwitchAPI-0d97664/docs/modules/twitchAPI.type.rst000066400000000000000000000001631463733066200235150ustar00rootroot00000000000000 .. automodule:: twitchAPI.type :members: :undoc-members: :show-inheritance: :inherited-members:Teekeks-pyTwitchAPI-0d97664/docs/requirements.txt000066400000000000000000000002601463733066200217610ustar00rootroot00000000000000enum-tools[sphinx] sphinx_toolbox aiohttp python-dateutil sphinx pygments typing_extensions sphinx-autodoc-typehints pydata-sphinx-theme==0.14.4 recommonmark sphinx-paramlinks Teekeks-pyTwitchAPI-0d97664/docs/tutorial/000077500000000000000000000000001463733066200203425ustar00rootroot00000000000000Teekeks-pyTwitchAPI-0d97664/docs/tutorial/chat-use-middleware.rst000066400000000000000000000275361463733066200247350ustar00rootroot00000000000000Chat - Introduction to Middleware ================================= In this tutorial, we will go over a few examples on how to use and write your own chat command middleware. Basics ****** Command Middleware can be understood as a set of filters which decide if a chat command should be executed by a user. 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. There are two types of command middleware: 1. global command middleware: this will be used to check any command that might be run 2. single command middleware: this will only be used to check a single command if it might be run Example setup ************* The following basic chat example will be used in this entire tutorial .. code-block:: python :linenos: import asyncio from twitchAPI import Twitch from twitchAPI.chat import Chat, ChatCommand from twitchAPI.oauth import UserAuthenticationStorageHelper from twitchAPI.types import AuthScope APP_ID = 'your_app_id' APP_SECRET = 'your_app_secret' SCOPES = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] TARGET_CHANNEL = ['your_first_channel', 'your_second_channel'] async def command_one(cmd: ChatCommand): await cmd.reply('This is the first command!') async def command_two(cmd: ChatCommand): await cmd.reply('This is the second command!') async def run(): twitch = await Twitch(APP_ID, APP_SECRET) helper = UserAuthenticationStorageHelper(twitch, SCOPES) await helper.bind() chat = await Chat(twitch, initial_channel=TARGET_CHANNEL) chat.register_command('one', command_one) chat.register_command('two', command_two) chat.start() try: input('press Enter to shut down...\n') except KeyboardInterrupt: pass finally: chat.stop() await twitch.close() asyncio.run(run()) Global Middleware ***************** 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`. The highlighted lines in the code below show how easy it is to set this up: .. code-block:: python :linenos: :emphasize-lines: 4,28,29 import asyncio from twitchAPI import Twitch from twitchAPI.chat import Chat, ChatCommand from twitchAPI.chat.middleware import UserRestriction, ChannelRestriction from twitchAPI.oauth import UserAuthenticationStorageHelper from twitchAPI.types import AuthScope APP_ID = 'your_app_id' APP_SECRET = 'your_app_secret' SCOPES = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] TARGET_CHANNEL = ['your_first_channel', 'your_second_channel'] async def command_one(cmd: ChatCommand): await cmd.reply('This is the first command!') async def command_two(cmd: ChatCommand): await cmd.reply('This is the second command!') async def run(): twitch = await Twitch(APP_ID, APP_SECRET) helper = UserAuthenticationStorageHelper(twitch, SCOPES) await helper.bind() chat = await Chat(twitch, initial_channel=TARGET_CHANNEL) chat.register_command_middleware(UserRestriction(allowed_users=['user1'])) chat.register_command_middleware(ChannelRestriction(allowed_channel=['your_first_channel'])) chat.register_command('one', command_one) chat.register_command('two', command_two) chat.start() try: input('press Enter to shut down...\n') except KeyboardInterrupt: pass finally: chat.stop() await twitch.close() asyncio.run(run()) Single Command Middleware ************************* Given the above example, we now want to only restrict :code:`!one` to be used by the streamer of the channel its executed in. The highlighted lines in the code below show how easy it is to set this up: .. code-block:: python :linenos: :emphasize-lines: 4, 29 import asyncio from twitchAPI import Twitch from twitchAPI.chat import Chat, ChatCommand from twitchAPI.chat.middleware import StreamerOnly from twitchAPI.oauth import UserAuthenticationStorageHelper from twitchAPI.types import AuthScope APP_ID = 'your_app_id' APP_SECRET = 'your_app_secret' SCOPES = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] TARGET_CHANNEL = ['your_first_channel', 'your_second_channel'] async def command_one(cmd: ChatCommand): await cmd.reply('This is the first command!') async def command_two(cmd: ChatCommand): await cmd.reply('This is the second command!') async def run(): twitch = await Twitch(APP_ID, APP_SECRET) helper = UserAuthenticationStorageHelper(twitch, SCOPES) await helper.bind() chat = await Chat(twitch, initial_channel=TARGET_CHANNEL) chat.register_command('one', command_one, command_middleware=[StreamerOnly()]) chat.register_command('two', command_two) chat.start() try: input('press Enter to shut down...\n') except KeyboardInterrupt: pass finally: chat.stop() await twitch.close() asyncio.run(run()) Using Execute Blocked Handlers ****************************** Execute blocked handlers are a function which will be called whenever the execution of a command was blocked. You can define a default handler to be used for any middleware that blocks a command execution and/or set one per middleware that will only be used when that specific middleware blocked the execution of a command. Note: You can mix and match a default handler with middleware specific handlers as much as you want. Using a default handler ----------------------- A default handler will be called whenever the execution of a command is blocked by a middleware which has no specific handler set. You can define a simple handler which just replies to the user as follows using the global middleware example: :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. .. code-block:: python :linenos: :emphasize-lines: 23, 24, 37 import asyncio from twitchAPI import Twitch from twitchAPI.chat import Chat, ChatCommand from twitchAPI.chat.middleware import UserRestriction, ChannelRestriction from twitchAPI.oauth import UserAuthenticationStorageHelper from twitchAPI.types import AuthScope APP_ID = 'your_app_id' APP_SECRET = 'your_app_secret' SCOPES = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] TARGET_CHANNEL = ['your_first_channel', 'your_second_channel'] async def command_one(cmd: ChatCommand): await cmd.reply('This is the first command!') async def command_two(cmd: ChatCommand): await cmd.reply('This is the second command!') async def handle_command_blocked(cmd: ChatCommand): await cmd.reply(f'You are not allowed to use {cmd.name}!') async def run(): twitch = await Twitch(APP_ID, APP_SECRET) helper = UserAuthenticationStorageHelper(twitch, SCOPES) await helper.bind() chat = await Chat(twitch, initial_channel=TARGET_CHANNEL) chat.register_command_middleware(UserRestriction(allowed_users=['user1'])) chat.register_command_middleware(ChannelRestriction(allowed_channel=['your_first_channel'])) chat.register_command('one', command_one) chat.register_command('two', command_two) chat.default_command_execution_blocked_handler = handle_command_blocked chat.start() try: input('press Enter to shut down...\n') except KeyboardInterrupt: pass finally: chat.stop() await twitch.close() asyncio.run(run()) Using a middleware specific handler ----------------------------------- A middleware specific handler can be used to change the response based on which middleware blocked the execution of a command. Note that this can again be both set for command specific middleware as well as global middleware. For this example we will only look at global middleware but the method is exactly the same for command specific one. To set a middleware specific handler, you have to set :const:`~twitchAPI.chat.middleware.BaseCommandMiddleware.execute_blocked_handler`. For the preimplemented middleware in this library, you can always pass this in the init of the middleware. In the following example we will be responding different based on which middleware blocked the command. .. code-block:: python :linenos: :emphasize-lines: 23, 24, 27, 28, 36, 37, 38, 39 import asyncio from twitchAPI import Twitch from twitchAPI.chat import Chat, ChatCommand from twitchAPI.chat.middleware import UserRestriction, ChannelRestriction from twitchAPI.oauth import UserAuthenticationStorageHelper from twitchAPI.types import AuthScope APP_ID = 'your_app_id' APP_SECRET = 'your_app_secret' SCOPES = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] TARGET_CHANNEL = ['your_first_channel', 'your_second_channel'] async def command_one(cmd: ChatCommand): await cmd.reply('This is the first command!') async def command_two(cmd: ChatCommand): await cmd.reply('This is the second command!') async def handle_blocked_user(cmd: ChatCommand): await cmd.reply(f'Only user1 is allowed to use {cmd.name}!') async def handle_blocked_channel(cmd: ChatCommand): await cmd.reply(f'{cmd.name} can only be used in channel your_first_channel!') async def run(): twitch = await Twitch(APP_ID, APP_SECRET) helper = UserAuthenticationStorageHelper(twitch, SCOPES) await helper.bind() chat = await Chat(twitch, initial_channel=TARGET_CHANNEL) chat.register_command_middleware(UserRestriction(allowed_users=['user1'], execute_blocked_handler=handle_blocked_user)) chat.register_command_middleware(ChannelRestriction(allowed_channel=['your_first_channel'], execute_blocked_handler=handle_blocked_channel)) chat.register_command('one', command_one) chat.register_command('two', command_two) chat.start() try: input('press Enter to shut down...\n') except KeyboardInterrupt: pass finally: chat.stop() await twitch.close() asyncio.run(run()) Write your own Middleware ************************* You can also write your own middleware to implement custom logic, you only have to extend the class :const:`~twitchAPI.chat.middleware.BaseCommandMiddleware`. In the following example, we will create a middleware which allows the command to execute in 50% of the times its executed. .. code-block:: python from typing import Callable, Optional, Awaitable class MyOwnCoinFlipMiddleware(BaseCommandMiddleware): # it is best practice to add this part of the init function to be compatible with the default middlewares # but you can also leave this out should you know you dont need it def __init__(self, execute_blocked_handler: Optional[Callable[[ChatCommand], Awaitable[None]]] = None): self.execute_blocked_handler = execute_blocked_handler async def can_execute(cmd: ChatCommand) -> bool: # add your own logic here, return True if the command should execute and False otherwise return random.choice([True, False]) async def was_executed(cmd: ChatCommand): # this will be called whenever a command this Middleware is attached to was executed, use this to update your internal state # since this is a basic example, we do nothing here pass Now use this middleware as any other: .. code-block:: python chat.register_command('ban-me', execute_ban_me, command_middleware=[MyOwnCoinFlipMiddleware()]) Teekeks-pyTwitchAPI-0d97664/docs/tutorial/mocking.rst000066400000000000000000000143511463733066200225270ustar00rootroot00000000000000Mocking with twitch-cli ======================= Twitch CLI is a tool provided by twitch which can be used to mock API calls and EventSub. To get started, first install and set up ``twitch-cli`` as described here: https://dev.twitch.tv/docs/cli/ Basic setup ----------- 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`. To run the mock server, run ``twitch mock-api start`` Mocking App Authentication and API ---------------------------------- The following code example sets us up with app auth and uses the mock API to get user information: .. code-block:: python import asyncio from twitchAPI.helper import first from twitchAPI.twitch import Twitch CLIENT_ID = 'GENERATED_CLIENT_ID' CLIENT_SECRET = 'GENERATED_CLIENT_SECRET' USER_ID = '53100947' async def run(): twitch = await Twitch(CLIENT_ID, CLIENT_SECRET, base_url='http://localhost:8080/mock/', auth_base_url='http://localhost:8080/auth/') user = await first(twitch.get_users(user_ids=USER_ID)) print(user.login) await twitch.close() asyncio.run(run()) Mocking User Authentication --------------------------- In the following example you see how to set up mocking with a user authentication. 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. .. code-block:: python import asyncio from twitchAPI.oauth import UserAuthenticator from twitchAPI.helper import first from twitchAPI.twitch import Twitch CLIENT_ID = 'GENERATED_CLIENT_ID' CLIENT_SECRET = 'GENERATED_CLIENT_SECRET' USER_ID = '53100947' async def run(): twitch = await Twitch(CLIENT_ID, CLIENT_SECRET, base_url='http://localhost:8080/mock/', auth_base_url='http://localhost:8080/auth/') twitch.auto_refresh_auth = False auth = UserAuthenticator(twitch, [], auth_base_url='http://localhost:8080/auth/') token = await auth.mock_authenticate(USER_ID) await twitch.set_user_authentication(token, []) user = await first(twitch.get_users()) print(user.login) await twitch.close() asyncio.run(run()) Mocking EventSub Webhook ------------------------ Since the EventSub subscription endpoints are not mocked in twitch-cli, we need to subscribe to events on the live api. But we can then trigger events from within twitch-cli. 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. .. code-block:: python import asyncio from twitchAPI.oauth import UserAuthenticationStorageHelper from twitchAPI.eventsub.webhook import EventSubWebhook from twitchAPI.object.eventsub import ChannelSubscribeEvent from twitchAPI.helper import first from twitchAPI.twitch import Twitch from twitchAPI.type import AuthScope CLIENT_ID = 'REAL_CLIENT_ID' CLIENT_SECRET = 'REAL_CLIENT_SECRET' EVENTSUB_URL = 'https://my.eventsub.url' async def on_subscribe(data: ChannelSubscribeEvent): print(f'{data.event.user_name} just subscribed!') async def run(): twitch = await Twitch(CLIENT_ID, CLIENT_SECRET) auth = UserAuthenticationStorageHelper(twitch, [AuthScope.CHANNEL_READ_SUBSCRIPTIONS]) await auth.bind() user = await first(twitch.get_users()) eventsub = EventSubWebhook(EVENTSUB_URL, 8080, twitch) eventsub.start() sub_id = await eventsub.listen_channel_subscribe(user.id, on_subscribe) print(f'twitch event trigger channel.subscribe -F {EVENTSUB_URL}/callback -t {user.id} -u {sub_id} -s {eventsub.secret}') try: input('press ENTER to stop') finally: await eventsub.stop() await twitch.close() asyncio.run(run()) Mocking EventSub Websocket -------------------------- 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`` We once again mock both the app and user auth. 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. .. code-block:: python import asyncio from twitchAPI.oauth import UserAuthenticator from twitchAPI.eventsub.websocket import EventSubWebsocket from twitchAPI.object.eventsub import ChannelSubscribeEvent from twitchAPI.helper import first from twitchAPI.twitch import Twitch from twitchAPI.type import AuthScope CLIENT_ID = 'GENERATED_CLIENT_ID' CLIENT_SECRET = 'GENERATED_CLIENT_SECRET' USER_ID = '53100947' async def on_subscribe(data: ChannelSubscribeEvent): print(f'{data.event.user_name} just subscribed!') async def run(): twitch = await Twitch(CLIENT_ID, CLIENT_SECRET, base_url='http://localhost:8080/mock/', auth_base_url='http://localhost:8080/auth/') twitch.auto_refresh_auth = False auth = UserAuthenticator(twitch, [AuthScope.CHANNEL_READ_SUBSCRIPTIONS], auth_base_url='http://localhost:8080/auth/') token = await auth.mock_authenticate(USER_ID) await twitch.set_user_authentication(token, [AuthScope.CHANNEL_READ_SUBSCRIPTIONS]) user = await first(twitch.get_users()) eventsub = EventSubWebsocket(twitch, connection_url='ws://127.0.0.1:8080/ws', subscription_url='http://127.0.0.1:8080/') eventsub.start() sub_id = await eventsub.listen_channel_subscribe(user.id, on_subscribe) print(f'twitch event trigger channel.subscribe -t {user.id} -u {sub_id} -T websocket') try: input('press ENTER to stop\n') finally: await eventsub.stop() await twitch.close() asyncio.run(run()) Teekeks-pyTwitchAPI-0d97664/docs/tutorial/reuse-user-token.rst000066400000000000000000000064171463733066200243210ustar00rootroot00000000000000Reuse user tokens with UserAuthenticationStorageHelper ====================================================== In this tutorial, we will look at different ways to use :const:`~twitchAPI.oauth.UserAuthenticationStorageHelper`. Basic Use Case -------------- This is the most basic example on how to use this helper. 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. 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. .. code-block:: python :linenos: from twitchAPI import Twitch from twitchAPI.oauth import UserAuthenticationStorageHelper from twitchAPI.types import AuthScope APP_ID = 'my_app_id' APP_SECRET = 'my_app_secret' USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] async def run(): twitch = await Twitch(APP_ID, APP_SECRET) helper = UserAuthenticationStorageHelper(twitch, USER_SCOPE) await helper.bind() # do things await twitch.close() # lets run our setup asyncio.run(run()) Use a different file to store your token ---------------------------------------- You can specify a different file in which the token should be stored in like this: .. code-block:: python :linenos: :emphasize-lines: 4, 15 from twitchAPI import Twitch from twitchAPI.oauth import UserAuthenticationStorageHelper from twitchAPI.types import AuthScope from pathlib import PurePath APP_ID = 'my_app_id' APP_SECRET = 'my_app_secret' USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] async def run(): twitch = await Twitch(APP_ID, APP_SECRET) helper = UserAuthenticationStorageHelper(twitch, USER_SCOPE, storage_path=PurePath('/my/new/path/file.json')) await helper.bind() # do things await twitch.close() # lets run our setup asyncio.run(run()) Use custom token generation code -------------------------------- Sometimes (for example for headless setups), the default UserAuthenticator is not good enough. For these cases, you can use your own function. .. code-block:: python :linenos: :emphasize-lines: 10, 11, 12, 18 from twitchAPI import Twitch from twitchAPI.oauth import UserAuthenticationStorageHelper from twitchAPI.types import AuthScope APP_ID = 'my_app_id' APP_SECRET = 'my_app_secret' USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] async def my_token_generator(twitch: Twitch, target_scope: List[AuthScope]) -> (str, str): # generate new token + refresh token here and return it return 'token', 'refresh_token' async def run(): twitch = await Twitch(APP_ID, APP_SECRET) helper = UserAuthenticationStorageHelper(twitch, USER_SCOPE, auth_generator_func=my_token_generator) await helper.bind() # do things await twitch.close() # lets run our setup asyncio.run(run()) Teekeks-pyTwitchAPI-0d97664/docs/tutorial/user-auth-headless.rst000066400000000000000000000037311463733066200246030ustar00rootroot00000000000000Generate a User Auth Token Headless =================================== This is a example on how to integrate :const:`~twitchAPI.oauth.UserAuthenticator` into your headless app. This example uses the popular server software `flask `__ but it can easily adapted to other software. .. 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 `__ While this example works as is, it is highly likely that you need to modify this heavily in accordance with your use case. .. code-block:: python import asyncio from twitchAPI.twitch import Twitch from twitchAPI.oauth import UserAuthenticator from twitchAPI.type import AuthScope, TwitchAPIException from flask import Flask, redirect, request APP_ID = 'my_app_id' APP_SECRET = 'my_app_secret' TARGET_SCOPE = [AuthScope.CHAT_EDIT, AuthScope.CHAT_READ] MY_URL = 'http://localhost:5000/login/confirm' app = Flask(__name__) twitch: Twitch auth: UserAuthenticator @app.route('/login') def login(): return redirect(auth.return_auth_url()) @app.route('/login/confirm') async def login_confirm(): state = request.args.get('state') if state != auth.state: return 'Bad state', 401 code = request.args.get('code') if code is None: return 'Missing code', 400 try: token, refresh = await auth.authenticate(user_token=code) await twitch.set_user_authentication(token, TARGET_SCOPE, refresh) except TwitchAPIException as e: return 'Failed to generate auth token', 400 return 'Sucessfully authenticated!' async def twitch_setup(): global twitch, auth twitch = await Twitch(APP_ID, APP_SECRET) auth = UserAuthenticator(twitch, TARGET_SCOPE, url=MY_URL) asyncio.run(twitch_setup()) Teekeks-pyTwitchAPI-0d97664/docs/tutorials.rst000066400000000000000000000006321463733066200212600ustar00rootroot00000000000000:orphan: Tutorials ========= This is a collection of detailed Tutorials regarding this library. If you want to suggest a tutorial topic, you can do so here on github: https://github.com/Teekeks/pyTwitchAPI/discussions/213 Available Tutorials ------------------- .. toctree:: :maxdepth: 1 tutorial/mocking tutorial/reuse-user-token tutorial/user-auth-headless tutorial/chat-use-middleware Teekeks-pyTwitchAPI-0d97664/docs/v3-migration.rst000066400000000000000000000116131463733066200215520ustar00rootroot00000000000000:orphan: v2 to v3 migration guide ======================== With version 3, this library made the switch from being mixed sync and async to being fully async. 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. But this means that v2 and v3 are not compatible. In this guide I will give some basic help on how to migrate your existing code. .. 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! **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!** Library Initialization ---------------------- You now need to await the Twitch Object and refresh callbacks are now async. .. code-block:: python :caption: V2 (before) from twitchAPI.twitch import Twitch def user_refresh(token: str, refresh_token: str): print(f'my new user token is: {token}') def app_refresh(token: str): print(f'my new app token is: {token}') twitch = Twitch('app_id', 'app_secret') twitch.app_auth_refresh_callback = app_refresh twitch.user_auth_refresh_callback = user_refresh .. code-block:: python :caption: V3 (now) from twitchAPI.twitch import Twitch async def user_refresh(token: str, refresh_token: str): print(f'my new user token is: {token}') async def app_refresh(token: str): print(f'my new app token is: {token}') twitch = await Twitch('my_app_id', 'my_app_secret') twitch.app_auth_refresh_callback = app_refresh twitch.user_auth_refresh_callback = user_refresh Working with the API results ---------------------------- As detailed above, the API now returns Objects instead of pure dictionaries. Below are how each one has to be handled. View the documentation of each API method to see which type is returned. TwitchObject ^^^^^^^^^^^^ A lot of API calls return a child of :py:const:`~twitchAPI.object.TwitchObject` in some way (either directly or via generator). You can always use the :py:const:`~twitchAPI.object.TwitchObject.to_dict()` method to turn that object to a dictionary. Example: .. code-block:: python blocked_term = await twitch.add_blocked_term('broadcaster_id', 'moderator_id', 'bad_word') print(blocked_term.id) IterTwitchObject ^^^^^^^^^^^^^^^^ Some API calls return a special type of TwitchObject. 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 outside of that List. Example: .. code-block:: python lb = await twitch.get_bits_leaderboard() print(lb.total) for e in lb: print(f'#{e.rank:02d} - {e.user_name}: {e.score}') AsyncIterTwitchObject ^^^^^^^^^^^^^^^^^^^^^ A few API calls will have usefull data outside of the list the pagination itterates over. For those cases, this object exist. Example: .. code-block:: python schedule = await twitch.get_channel_stream_schedule('user_id') print(schedule.broadcaster_name) async for segment in schedule: print(segment.title) AsyncGenerator ^^^^^^^^^^^^^^ AsyncGenerators are used to automatically itterate over all possible resuts of your API call, this will also automatically handle pagination for you. 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 exit conditions in such cases. 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. Example: .. code-block:: python async for tag in twitch.get_all_stream_tags(): print(tag.tag_id) PubSub ------ All callbacks are now async. .. code-block:: python :caption: V2 (before) # this will be called def callback_whisper(uuid: UUID, data: dict) -> None: print('got callback for UUID ' + str(uuid)) pprint(data) .. code-block:: python :caption: V3 (now) async def callback_whisper(uuid: UUID, data: dict) -> None: print('got callback for UUID ' + str(uuid)) pprint(data) EventSub -------- All `listen_` and `unsubscribe_` functions are now async .. code-block:: python :caption: listen and unsubscribe in V2 (before) event_sub.unsubscribe_all() event_sub.listen_channel_follow(user_id, on_follow) .. code-block:: python :caption: listen and unsubscribe in V3 (now) await event_sub.unsubscribe_all() await event_sub.listen_channel_follow(user_id, on_follow) :const:`~twitchAPI.eventsub.EventSub.stop()` is now async .. code-block:: python :caption: stop() in V2 (before) event_sub.stop() .. code-block:: python :caption: stop() in V3 (now) await event_sub.stop() Teekeks-pyTwitchAPI-0d97664/docs/v4-migration.rst000066400000000000000000000052111463733066200215500ustar00rootroot00000000000000:orphan: v3 to v4 migration guide ======================== With v4 of this library, some modules got reorganized and EventSub got a bunch of major changes. In this guide I will give some basic help on how to migrate your existing code. General module changes ---------------------- - ``twitchAPI.types`` was renamed to :const:`twitchAPI.type` - Most objects where moved from ``twitchAPI.object`` to :const:`twitchAPI.object.api` - The following Objects where moved from ``twitchAPI.object`` to :const:`twitchAPI.object.base`: - :const:`~twitchAPI.object.base.TwitchObject` - :const:`~twitchAPI.object.base.IterTwitchObject` - :const:`~twitchAPI.object.base.AsyncIterTwitchObject` EventSub -------- 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` Topic callbacks now no longer use plain dictionaries but objects. See :ref:`eventsub-available-topics` for more information which topic uses which object. .. code-block:: python :caption: V3 (before) from twitchAPI.eventsub import EventSub import asyncio EVENTSUB_URL = 'https://url.to.your.webhook.com' async def on_follow(data: dict): print(data) async def eventsub_example(): # twitch setup is left out of this example event_sub = EventSub(EVENTSUB_URL, APP_ID, 8080, twitch) await event_sub.unsubscribe_all() event_sub.start() await event_sub.listen_channel_follow_v2(user.id, user.id, on_follow) try: input('press Enter to shut down...') finally: await event_sub.stop() await twitch.close() print('done') asyncio.run(eventsub_example()) .. code-block:: python :caption: V4 (now) from twitchAPI.eventsub.webhook import EventSubWebhook from twitchAPI.object.eventsub import ChannelFollowEvent import asyncio EVENTSUB_URL = 'https://url.to.your.webhook.com' async def on_follow(data: ChannelFollowEvent): print(f'{data.event.user_name} now follows {data.event.broadcaster_user_name}!') async def eventsub_webhook_example(): # twitch setup is left out of this example eventsub = EventSubWebhook(EVENTSUB_URL, 8080, twitch) await eventsub.unsubscribe_all() eventsub.start() await eventsub.listen_channel_follow_v2(user.id, user.id, on_follow) try: input('press Enter to shut down...') finally: await eventsub.stop() await twitch.close() print('done') asyncio.run(eventsub_webhook_example()) Teekeks-pyTwitchAPI-0d97664/pyproject.toml000066400000000000000000000001321463733066200204570ustar00rootroot00000000000000[build-system] requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" Teekeks-pyTwitchAPI-0d97664/requirements.txt000066400000000000000000000000701463733066200210300ustar00rootroot00000000000000aiohttp>=3.9.3 python-dateutil>=2.8.2 typing_extensions Teekeks-pyTwitchAPI-0d97664/setup.cfg000066400000000000000000000021221463733066200173650ustar00rootroot00000000000000[metadata] name=twitchAPI url=https://github.com/Teekeks/pyTwitchAPI author=Lena "Teekeks" During author_email=info@teawork.de description=A Python 3.7+ implementation of the Twitch Helix API, PubSub, EventSub and Chat long_description=file:README.md long_description_content_type=text/markdown license=MIT classifiers= Development Status :: 5 - Production/Stable Intended Audience :: Developers Topic :: Communications Topic :: Software Development :: Libraries Topic :: Software Development :: Libraries :: Python Modules Topic :: Software Development :: Libraries :: Application Frameworks License :: OSI Approved :: MIT License Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 [options] zip_safe=true install_requirements= aiohttp>=3.9.3 python-dateutil>=2.8.2 typing_extensions Teekeks-pyTwitchAPI-0d97664/setup.py000066400000000000000000000022341463733066200172620ustar00rootroot00000000000000# Copyright (c) 2022. Lena "Teekeks" During from setuptools import setup, find_packages version = '' with open('twitchAPI/__init__.py') as f: for line in f.readlines(): if line.startswith('__version__'): version = line.split('= \'')[-1][:-2].strip() if version.endswith(('a', 'b', 'rc')): try: import subprocess p = subprocess.Popen(['git', 'rev-list', '--count', 'HEAD'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = p.communicate() if out: version += out.decode('utf-8').strip() p = subprocess.Popen(['git', 'rev-parse', '--short', 'HEAD'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = p.communicate() if out: version += '+g' + out.decode('utf-8').strip() except: pass setup( packages=find_packages(), version=version, keywords=['twitch', 'twitch.tv', 'chat', 'bot', 'event sub', 'EventSub', 'pub sub', 'PubSub', 'helix', 'api'], install_requires=[ 'aiohttp>=3.9.3', 'python-dateutil>=2.8.2', 'typing_extensions' ], package_data={'twitchAPI': ['py.typed']} ) Teekeks-pyTwitchAPI-0d97664/twitchAPI/000077500000000000000000000000001463733066200174035ustar00rootroot00000000000000Teekeks-pyTwitchAPI-0d97664/twitchAPI/__init__.py000066400000000000000000000000571463733066200215160ustar00rootroot00000000000000VERSION = (4, 2, 1, '') __version__ = '4.2.1' Teekeks-pyTwitchAPI-0d97664/twitchAPI/chat/000077500000000000000000000000001463733066200203225ustar00rootroot00000000000000Teekeks-pyTwitchAPI-0d97664/twitchAPI/chat/__init__.py000066400000000000000000001717521463733066200224500ustar00rootroot00000000000000# Copyright (c) 2022. Lena "Teekeks" During """ Twitch Chat Bot --------------- A simple twitch chat bot.\n Chat bots can join channels, listen to chat and reply to messages, commands, subscriptions and many more. ******** Commands ******** Chat commands are specific messages user can send in chat in order to trigger some action of your bot. Example: .. code-block:: : !say Hello world : User123 asked me to say "Hello world" You can register listeners to chat commands using :const:`~twitchAPI.chat.Chat.register_command()`. The bot prefix can be set by using :const:`~twitchAPI.chat.Chat.set_prefix()`, the default is :code:`!` Your command listener function needs to be async and take in one parameter of type :const:`~twitchAPI.chat.ChatCommand`. Example: .. code-block:: python async def say_command_handler(cmd: ChatCommand): await cmd.reply(f'{cmd.user.name} asked me to say "{cmd.parameter}") chat.register_command('say', say_command_handler) ****************** Command Middleware ****************** Command Middleware is a way to control when a command should be executed. See :doc:`/modules/twitchAPI.chat.middleware` and :doc:`/tutorial/chat-use-middleware` for more information. ****** Events ****** You can listen to different events happening in the chat rooms you joined. Generally you register a event listener using :const:`~twitchAPI.chat.Chat.register_event()`. The first parameter has to be of type :const:`~twitchAPI.type.ChatEvent` and the second one is your listener function. Those Listeners always have to be async functions taking in one parameter (the payload). The Payload type is described below. Example: .. code-block:: python async def on_ready(cmd: EventData): await cmd.chat.join_room('teekeks42') chat.register_event(ChatEvent.READY, on_ready) Available Events ================ .. list-table:: :header-rows: 1 * - Event Name - Event Data - Description * - Bot Ready - ChatEvent: :obj:`~twitchAPI.type.ChatEvent.READY` |br| Payload: :const:`~twitchAPI.chat.EventData` - This Event is triggered when the bot is started up and ready to join channels. * - Message Send - ChatEvent: :const:`~twitchAPI.type.ChatEvent.MESSAGE` |br| Payload: :const:`~twitchAPI.chat.ChatMessage` - This Event is triggered when someone wrote a message in a channel we joined * - Channel Subscription - ChatEvent: :const:`~twitchAPI.type.ChatEvent.SUB` |br| Payload: :const:`~twitchAPI.chat.ChatSub` - This Event is triggered when someone subscribed to a channel we joined. * - Raid - ChatEvent: :const:`~twitchAPI.type.ChatEvent.RAID` |br| Payload: :const:`dict` - Triggered when a channel gets raided * - Channel Config Changed - ChatEvent: :const:`~twitchAPI.type.ChatEvent.ROOM_STATE_CHANGE` |br| Payload: :const:`~twitchAPI.chat.RoomStateChangeEvent` - Triggered when a channel is changed (e.g. sub only mode was enabled) * - User Channel Join - ChatEvent: :const:`~twitchAPI.type.ChatEvent.JOIN` |br| Payload: :const:`~twitchAPI.chat.JoinEvent` - Triggered when someone other than the bot joins a channel. |br| **This will not always trigger, depending on channel size** * - User Channel Leave - ChatEvent: :const:`~twitchAPI.type.ChatEvent.USER_LEFT` |br| Payload: :const:`~twitchAPI.chat.LeftEvent` - Triggered when someone other than the bot leaves a channel. |br| **This will not always trigger, depending on channel size** * - Bot Channel Join - ChatEvent: :const:`~twitchAPI.type.ChatEvent.JOINED` |br| Payload: :const:`~twitchAPI.chat.JoinedEvent` - Triggered when the bot joins a channel * - Bot Channel Leave - ChatEvent: :const:`~twitchAPI.type.ChatEvent.LEFT` |br| Payload: :const:`~twitchAPI.chat.LeftEvent` - Triggered when the bot left a channel * - Message Delete - ChatEvent: :const:`~twitchAPI.type.ChatEvent.MESSAGE_DELETE` |br| Payload: :const:`~twitchAPI.chat.MessageDeletedEvent` - Triggered when a single message in a channel got deleted * - User Messages Cleared - ChatEvent: :const:`~twitchAPI.type.ChatEvent.CHAT_CLEARED` |br| Payload: :const:`~twitchAPI.chat.ClearChatEvent` - Triggered when a user was banned, timed out and/or all messaged from a user where deleted * - Bot Reveives Whisper Message - ChatEvent: :const:`~twitchAPI.type.ChatEvent.WHISPER` |br| Payload: :const:`~twitchAPI.chat.WhisperEvent` - Triggered when someone whispers to your bot. |br| **You need the** :const:`~twitchAPI.type.AuthScope.WHISPERS_READ` **Auth Scope to receive this Event.** * - Server Notice - ChatEvent: :const:`~twitchAPI.type.ChatEvent.NOTICE` |br| Payload: :const:`~twitchAPI.chat.NoticeEvent` - Triggered when server sends a notice message. ************ Code example ************ .. code-block:: python from twitchAPI.twitch import Twitch from twitchAPI.oauth import UserAuthenticator from twitchAPI.type import AuthScope, ChatEvent from twitchAPI.chat import Chat, EventData, ChatMessage, ChatSub, ChatCommand import asyncio APP_ID = 'my_app_id' APP_SECRET = 'my_app_secret' USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] TARGET_CHANNEL = 'teekeks42' # this will be called when the event READY is triggered, which will be on bot start async def on_ready(ready_event: EventData): print('Bot is ready for work, joining channels') # join our target channel, if you want to join multiple, either call join for each individually # or even better pass a list of channels as the argument await ready_event.chat.join_room(TARGET_CHANNEL) # you can do other bot initialization things in here # this will be called whenever a message in a channel was send by either the bot OR another user async def on_message(msg: ChatMessage): print(f'in {msg.room.name}, {msg.user.name} said: {msg.text}') # this will be called whenever someone subscribes to a channel async def on_sub(sub: ChatSub): print(f'New subscription in {sub.room.name}:\\n' f' Type: {sub.sub_plan}\\n' f' Message: {sub.sub_message}') # this will be called whenever the !reply command is issued async def test_command(cmd: ChatCommand): if len(cmd.parameter) == 0: await cmd.reply('you did not tell me what to reply with') else: await cmd.reply(f'{cmd.user.name}: {cmd.parameter}') # this is where we set up the bot async def run(): # set up twitch api instance and add user authentication with some scopes twitch = await Twitch(APP_ID, APP_SECRET) auth = UserAuthenticator(twitch, USER_SCOPE) token, refresh_token = await auth.authenticate() await twitch.set_user_authentication(token, USER_SCOPE, refresh_token) # create chat instance chat = await Chat(twitch) # register the handlers for the events you want # listen to when the bot is done starting up and ready to join channels chat.register_event(ChatEvent.READY, on_ready) # listen to chat messages chat.register_event(ChatEvent.MESSAGE, on_message) # listen to channel subscriptions chat.register_event(ChatEvent.SUB, on_sub) # there are more events, you can view them all in this documentation # you can directly register commands and their handlers, this will register the !reply command chat.register_command('reply', test_command) # we are done with our setup, lets start this bot up! chat.start() # lets run till we press enter in the console try: input('press ENTER to stop\\n') finally: # now we can close the chat bot and the twitch api client chat.stop() await twitch.close() # lets run our setup asyncio.run(run()) ******************* Class Documentation ******************* """ import asyncio import dataclasses import datetime import re import threading from asyncio import CancelledError from functools import partial from logging import getLogger, Logger from time import sleep import aiohttp from twitchAPI.twitch import Twitch from twitchAPI.object.api import TwitchUser from twitchAPI.helper import TWITCH_CHAT_URL, first, RateLimitBucket, RATE_LIMIT_SIZES, done_task_callback from twitchAPI.type import ChatRoom, TwitchBackendException, AuthType, AuthScope, ChatEvent, UnauthorizedException from typing import List, Optional, Union, Callable, Dict, Awaitable, Any, TYPE_CHECKING if TYPE_CHECKING: from twitchAPI.chat.middleware import BaseCommandMiddleware __all__ = ['Chat', 'ChatUser', 'EventData', 'ChatMessage', 'ChatCommand', 'ChatSub', 'ChatRoom', 'ChatEvent', 'RoomStateChangeEvent', 'JoinEvent', 'JoinedEvent', 'LeftEvent', 'ClearChatEvent', 'WhisperEvent', 'MessageDeletedEvent', 'NoticeEvent', 'HypeChat'] class ChatUser: """Represents a user in a chat channel """ def __init__(self, chat, parsed, name_override=None): self.chat: 'Chat' = chat """The :const:`twitchAPI.chat.Chat` instance""" self.name: str = parsed['source']['nick'] if parsed['source']['nick'] is not None else f'{chat.username}' """The name of the user""" if self.name[0] == ':': self.name = self.name[1:] if name_override is not None: self.name = name_override self.badge_info = parsed['tags'].get('badge-info') """All infos related to the badges of the user""" self.badges = parsed['tags'].get('badges') """The badges of the user""" self.color: str = parsed['tags'].get('color') """The color of the chat user if set""" self.display_name: str = parsed['tags'].get('display-name') """The display name, should usually be the same as name""" self.mod: bool = parsed['tags'].get('mod', '0') == '1' """if the user is a mod in chat channel""" self.subscriber: bool = parsed['tags'].get('subscriber') == '1' """if the user is a subscriber to the channel""" self.turbo: bool = parsed['tags'].get('turbo') == '1' """Indicates whether the user has site-wide commercial free mode enabled""" self.id: str = parsed['tags'].get('user-id') """The ID of the user""" self.user_type: str = parsed['tags'].get('user-type') """The type of user""" self.vip: bool = parsed['tags'].get('vip') == '1' """if the chatter is a channel VIP""" class EventData: """Represents a basic chat event""" def __init__(self, chat): self.chat: 'Chat' = chat """The :const:`twitchAPI.chat.Chat` instance""" class MessageDeletedEvent(EventData): def __init__(self, chat, parsed): super(MessageDeletedEvent, self).__init__(chat) self._room_name = parsed['command']['channel'][1:] self.message: str = parsed['parameters'] """The content of the message that got deleted""" self.user_name: str = parsed['tags'].get('login') """Username of the message author""" self.message_id: str = parsed['tags'].get('target-msg-id') """ID of the message that got deleted""" self.sent_timestamp: int = int(parsed['tags'].get('tmi-sent-ts')) """The timestamp the deleted message was send""" @property def room(self) -> Optional[ChatRoom]: """The room the message was deleted in""" return self.chat.room_cache.get(self._room_name) class RoomStateChangeEvent(EventData): """Triggered when a room state changed""" def __init__(self, chat, prev, new): super(RoomStateChangeEvent, self).__init__(chat) self.old: Optional[ChatRoom] = prev """The State of the room from before the change, might be Null if not in cache""" self.new: ChatRoom = new """The new room state""" @property def room(self) -> Optional[ChatRoom]: """Returns the Room from cache""" return self.chat.room_cache.get(self.new.name) class JoinEvent(EventData): """""" def __init__(self, chat, channel_name, user_name): super(JoinEvent, self).__init__(chat) self._name = channel_name self.user_name: str = user_name """The name of the user that joined""" @property def room(self) -> Optional[ChatRoom]: """The room the user joined to""" return self.chat.room_cache.get(self._name) class JoinedEvent(EventData): """""" def __init__(self, chat, channel_name, user_name): super(JoinedEvent, self).__init__(chat) self.room_name: str = channel_name """the name of the room the bot joined to""" self.user_name: str = user_name """the name of the bot""" class LeftEvent(EventData): """When the bot or a user left a room""" def __init__(self, chat, channel_name, room, user): super(LeftEvent, self).__init__(chat) self.room_name: str = channel_name """the name of the channel the bot left""" self.user_name: str = user """The name of the user that left the chat""" self.cached_room: Optional[ChatRoom] = room """the cached room state, might bo Null""" class HypeChat: def __init__(self, parsed): self.amount: int = int(parsed['tags'].get('pinned-chat-paid-amount')) """The value of the Hype Chat sent by the user.""" self.currency: str = parsed['tags'].get('pinned-chat-paid-currency') """The ISO 4217 alphabetic currency code the user has sent the Hype Chat in.""" self.exponent: int = int(parsed['tags'].get('pinned-chat-paid-exponent')) """Indicates how many decimal points this currency represents partial amounts in. Decimal points start from the right side of the value defined in :const:`~twitchAPI.chat.HypeChat.amount`""" self.level: str = parsed['tags'].get('pinned-chat-paid-level') """The level of the Hype Chat, in English.\n Possible Values are: :code:`ONE`, :code:`TWO`, :code:`THREE`, :code:`FOUR`, :code:`FIVE`, :code:`SIX`, :code:`SEVEN`, :code:`EIGHT`, :code:`NINE`, :code:`TEN`""" self.is_system_message: bool = parsed['tags'].get('pinned-chat-paid-is-system-message') == '1' """A Boolean value that determines if the message sent with the Hype Chat was filled in by the system.\n If True, the user entered no message and the body message was automatically filled in by the system.\n If False, the user provided their own message to send with the Hype Chat.""" class ChatMessage(EventData): """Represents a chat message""" def __init__(self, chat, parsed): super(ChatMessage, self).__init__(chat) self._parsed = parsed self.text: str = parsed['parameters'] """The message""" self.is_me: bool = False """Flag indicating if the message used the /me command""" result = _ME_REGEX.match(self.text) if result is not None: self.text = result.group('msg') self.is_me = True self.bits: int = int(parsed['tags'].get('bits', '0')) """The amount of Bits the user cheered""" self.sent_timestamp: int = int(parsed['tags'].get('tmi-sent-ts')) """the unix timestamp of when the message was sent""" self.reply_parent_msg_id: Optional[str] = parsed['tags'].get('reply-parent-msg-id') """An ID that uniquely identifies the parent message that this message is replying to.""" self.reply_parent_user_id: Optional[str] = parsed['tags'].get('reply-parent-user-id') """An ID that identifies the sender of the parent message.""" self.reply_parent_user_login: Optional[str] = parsed['tags'].get('reply-parent-user-login') """The login name of the sender of the parent message. """ self.reply_parent_display_name: Optional[str] = parsed['tags'].get('reply-parent-display-name') """The display name of the sender of the parent message.""" self.reply_parent_msg_body: Optional[str] = parsed['tags'].get('reply-parent-msg-body') """The text of the parent message""" self.reply_thread_parent_msg_id: Optional[str] = parsed['tags'].get('reply-thread-parent-msg-id') """An ID that uniquely identifies the top-level parent message of the reply thread that this message is replying to. Is :code:`None` if this message is not a reply.""" self.reply_thread_parent_user_login: Optional[str] = parsed['tags'].get('reply-thread-parent-user-login') """The login name of the sender of the top-level parent message. Is :code:`None` if this message is not a reply.""" self.emotes = parsed['tags'].get('emotes') """The emotes used in the message""" self.id: str = parsed['tags'].get('id') """the ID of the message""" self.hype_chat: Optional[HypeChat] = HypeChat(parsed) if parsed['tags'].get('pinned-chat-paid-level') is not None else None """Hype Chat related data, is None if the message was not a hype chat""" @property def room(self) -> Optional[ChatRoom]: """The channel the message was issued in""" return self.chat.room_cache.get(self._parsed['command']['channel'][1:]) @property def user(self) -> ChatUser: """The user that issued the message""" return ChatUser(self.chat, self._parsed) async def reply(self, text: str): """Reply to this message""" bucket = self.chat._get_message_bucket(self._parsed['command']['channel'][1:]) await bucket.put() await self.chat.send_raw_irc_message(f'@reply-parent-msg-id={self.id} PRIVMSG #{self.room.name} :{text}') class ChatCommand(ChatMessage): """Represents a command""" def __init__(self, chat, parsed): super(ChatCommand, self).__init__(chat, parsed) self.name: str = parsed['command'].get('bot_command') """the name of the command""" self.parameter: str = parsed['command'].get('bot_command_params', '') """the parameter given to the command""" async def send(self, message: str): """Sends a message to the channel the command was issued in :param message: the message you want to send """ await self.chat.send_message(self.room.name, message) class ChatSub: """Represents a sub to a channel""" def __init__(self, chat, parsed): self.chat: 'Chat' = chat """The :const:`twitchAPI.chat.Chat` instance""" self._parsed = parsed self.sub_type: str = parsed['tags'].get('msg-id') """The type of sub given""" self.sub_message: str = parsed['parameters'] if parsed['parameters'] is not None else '' """The message that was sent together with the sub""" self.sub_plan: str = parsed['tags'].get('msg-param-sub-plan') """the ID of the subscription plan that was used""" self.sub_plan_name: str = parsed['tags'].get('msg-param-sub-plan-name') """the name of the subscription plan that was used""" self.system_message: str = parsed['tags'].get('system-msg', '').replace('\\\\s', ' ') """the system message that was generated for this sub""" @property def room(self) -> Optional[ChatRoom]: """The room this sub was issued in""" return self.chat.room_cache.get(self._parsed['command']['channel'][1:]) class ClearChatEvent(EventData): def __init__(self, chat, parsed): super(ClearChatEvent, self).__init__(chat) self.room_name: str = parsed['command']['channel'][1:] """The name of the chat room the event happend in""" self.room_id: str = parsed['tags'].get('room-id') """The ID of the chat room the event happend in""" self.user_name: str = parsed['parameters'] """The name of the user whos messages got cleared""" self.duration: Optional[int] = int(parsed['tags']['ban-duration']) if parsed['tags'].get('ban-duration') not in (None, '') else None """duration of the timeout in seconds. None if user was not timed out""" self.banned_user_id: Optional[str] = parsed['tags'].get('target-user-id') """The ID of the user who got banned or timed out. if :const:`~twitchAPI.chat.ClearChatEvent.duration` is None, the user was banned. Will be None when the user was not banned nor timed out.""" self.sent_timestamp: int = int(parsed['tags'].get('tmi-sent-ts')) """The timestamp the event happend at""" @property def room(self) -> Optional[ChatRoom]: """The room this event was issued in. None on cache miss.""" return self.chat.room_cache.get(self.room_name) class WhisperEvent(EventData): def __init__(self, chat, parsed): super(WhisperEvent, self).__init__(chat) self._parsed = parsed self.message: str = parsed['parameters'] """The message that was send""" @property def user(self) -> ChatUser: """The user that DMed your bot""" return ChatUser(self.chat, self._parsed) class NoticeEvent(EventData): """Represents a server notice""" def __init__(self, chat, parsed): super(NoticeEvent, self).__init__(chat) self._room_name = parsed['command']['channel'][1:] """The name of the chat room the notice is from""" self.msg_id: str = parsed['tags'].get('msg-id') """Message ID of the notice, `Msg-id reference `__""" self.message: str = parsed['parameters'] """Description for the msg_id""" @property def room(self) -> Optional[ChatRoom]: """The room this notice is from""" return self.chat.room_cache.get(self._room_name) COMMAND_CALLBACK_TYPE = Callable[[ChatCommand], Awaitable[None]] EVENT_CALLBACK_TYPE = Callable[[Any], Awaitable[None]] CHATROOM_TYPE = Union[str, ChatRoom] _ME_REGEX = re.compile(r'^\x01ACTION (?P.+)\x01$') class Chat: """The chat bot instance""" def __init__(self, twitch: Twitch, connection_url: Optional[str] = None, is_verified_bot: bool = False, initial_channel: Optional[List[str]] = None, callback_loop: Optional[asyncio.AbstractEventLoop] = None, no_message_reset_time: Optional[float] = 10): """ :param twitch: A Authenticated twitch instance :param connection_url: alternative connection url |default|:code:`None` :param is_verified_bot: set to true if your bot is verified by twitch |default|:code:`False` :param initial_channel: List of channel which should be automatically joined on startup |default| :code:`None` :param callback_loop: The asyncio eventloop to be used for callbacks. \n Set this if you or a library you use cares about which asyncio event loop is running the callbacks. Defaults to the one used by Chat. :param no_message_reset_time: How many minutes of mo messages from Twitch before the connection is considered dead. Twitch sends a PING just under every 5 minutes and the bot must respond to them for Twitch to keep the connection active. At 10 minutes we've definitely missed at least one PING |default|:code:`10` """ self.logger: Logger = getLogger('twitchAPI.chat') """The logger used for Chat related log messages""" self._prefix: str = "!" self.twitch: Twitch = twitch """The twitch instance being used""" if not self.twitch.has_required_auth(AuthType.USER, [AuthScope.CHAT_READ]): raise ValueError('passed twitch instance is missing User Auth.') self.connection_url: str = connection_url if connection_url is not None else TWITCH_CHAT_URL """Alternative connection url |default|:code:`None`""" self.ping_frequency: int = 120 """Frequency in seconds for sending ping messages. This should usually not be changed.""" self.ping_jitter: int = 4 """Jitter in seconds for ping messages. This should usually not be changed.""" self._callback_loop = callback_loop self.no_message_reset_time: Optional[float] = no_message_reset_time self.listen_confirm_timeout: int = 30 """Time in second that any :code:`listen_` should wait for its subscription to be completed.""" self.reconnect_delay_steps: List[int] = [0, 1, 2, 4, 8, 16, 32, 64, 128] """Time in seconds between reconnect attempts""" self.log_no_registered_command_handler: bool = True """Controls if instances of commands being issued in chat where no handler exists should be logged. |default|:code:`True`""" self.__connection = None self._session = None self.__socket_thread: Optional[threading.Thread] = None self.__running: bool = False self.__socket_loop = None self.__startup_complete: bool = False self.__tasks = None self._ready = False self._send_buckets = {} self._join_target = [c[1:].lower() if c[0] == '#' else c.lower() for c in initial_channel] if initial_channel is not None else [] self._join_bucket = RateLimitBucket(10, 2000 if is_verified_bot else 20, 'channel_join', self.logger) self.__waiting_for_pong: bool = False self._event_handler = {} self._command_handler = {} self.room_cache: Dict[str, ChatRoom] = {} """internal cache of all chat rooms the bot is currently in""" self._room_join_locks = [] self._room_leave_locks = [] self._closing: bool = False self.join_timeout: int = 10 """Time in seconds till a channel join attempt times out""" self._mod_status_cache = {} self._subscriber_status_cache = {} self._channel_command_prefix = {} self._command_middleware: List['BaseCommandMiddleware'] = [] self._command_specific_middleware: Dict[str, List['BaseCommandMiddleware']] = {} self._task_callback = partial(done_task_callback, self.logger) self.default_command_execution_blocked_handler: Optional[Callable[[ChatCommand], Awaitable[None]]] = None """The default handler to be called should a command execution be blocked by a middleware that has no specific handler set.""" self.username: Optional[str] = None def __await__(self): t = asyncio.create_task(self._get_username()) yield from t return self async def _get_username(self): user: TwitchUser = await first(self.twitch.get_users()) self.username = user.login.lower() ################################################################################################################################################## # command parsing ################################################################################################################################################## def _parse_irc_message(self, message: str): parsed_message = { 'tags': {}, 'source': None, 'command': None, 'parameters': None } idx = 0 raw_tags_component = None raw_source_component = None raw_parameters_component = None if message[idx] == '@': end_idx = message.index(' ') raw_tags_component = message[1:end_idx] idx = end_idx + 1 if message[idx] == ':': end_idx = message.index(' ', idx) raw_source_component = message[idx:end_idx] idx = end_idx + 1 try: end_idx = message.index(':', idx) except ValueError: end_idx = len(message) raw_command_component = message[idx:end_idx].strip() if end_idx != len(message): idx = end_idx + 1 raw_parameters_component = message[idx::] parsed_message['command'] = self._parse_irc_command(raw_command_component) if parsed_message['command'] is None: return None if raw_tags_component is not None: parsed_message['tags'] = self._parse_irc_tags(raw_tags_component) parsed_message['source'] = self._parse_irc_source(raw_source_component) parsed_message['parameters'] = raw_parameters_component if parsed_message['command']['command'] == 'PRIVMSG': ch = parsed_message['command'].get('channel', '#')[1::] used_prefix = self._channel_command_prefix.get(ch, self._prefix) if raw_parameters_component is not None and raw_parameters_component.startswith(used_prefix): parsed_message['command'] = self._parse_irc_parameters(raw_parameters_component, parsed_message['command'], used_prefix) return parsed_message @staticmethod def _parse_irc_parameters(raw_parameters_component: str, command, prefix): command_parts = raw_parameters_component[len(prefix)::].strip() try: params_idx = command_parts.index(' ') except ValueError: command['bot_command'] = command_parts return command command['bot_command'] = command_parts[:params_idx] command['bot_command_params'] = command_parts[params_idx:].strip() return command @staticmethod def _parse_irc_source(raw_source_component: str): if raw_source_component is None: return None source_parts = raw_source_component.split('!') return { 'nick': source_parts[0] if len(source_parts) == 2 else None, 'host': source_parts[1] if len(source_parts) == 2 else source_parts[0] } @staticmethod def _parse_irc_tags(raw_tags_component: str): tags_to_ignore = ('client-nonce', 'flags') parsed_tags = {} tags = raw_tags_component.split(';') for tag in tags: parsed_tag = tag.split('=') tag_value = None if parsed_tag[1] == '' else parsed_tag[1] if parsed_tag[0] in ('badges', 'badge-info'): if tag_value is not None: d = {} badges = tag_value.split(',') for pair in badges: badge_parts = pair.split('/', 1) d[badge_parts[0]] = badge_parts[1] parsed_tags[parsed_tag[0]] = d else: parsed_tags[parsed_tag[0]] = None elif parsed_tag[0] == 'emotes': if tag_value is not None: d = {} emotes = tag_value.split('/') for emote in emotes: emote_parts = emote.split(':') text_positions = [] positions = emote_parts[1].split(',') for position in positions: pos_parts = position.split('-') text_positions.append({ 'start_position': pos_parts[0], 'end_position': pos_parts[1] }) d[emote_parts[0]] = text_positions parsed_tags[parsed_tag[0]] = d else: parsed_tags[parsed_tag[0]] = None elif parsed_tag[0] == 'emote-sets': parsed_tags[parsed_tag[0]] = tag_value.split(',') else: if parsed_tag[0] not in tags_to_ignore: parsed_tags[parsed_tag[0]] = tag_value return parsed_tags def _parse_irc_command(self, raw_command_component: str): command_parts = raw_command_component.split(' ') if command_parts[0] in ('JOIN', 'PART', 'NOTICE', 'CLEARCHAT', 'HOSTTARGET', 'PRIVMSG', 'USERSTATE', 'ROOMSTATE', '001', 'USERNOTICE', 'CLEARMSG', 'WHISPER'): parsed_command = { 'command': command_parts[0], 'channel': command_parts[1] } elif command_parts[0] in ('PING', 'GLOBALUSERSTATE', 'RECONNECT'): parsed_command = { 'command': command_parts[0] } elif command_parts[0] == 'CAP': parsed_command = { 'command': command_parts[0], 'is_cap_request_enabled': command_parts[2] == 'ACK' } elif command_parts[0] == '421': # unsupported command in parts 2 self.logger.warning(f'Unsupported IRC command: {command_parts[0]}') return None elif command_parts[0] == '353': parsed_command = { 'command': command_parts[0] } elif command_parts[0] in ('002', '003', '004', '366', '372', '375', '376'): self.logger.debug(f'numeric message: {command_parts[0]}\n{raw_command_component}') return None else: # unexpected command self.logger.warning(f'Unexpected command: {command_parts[0]}') return None return parsed_command ################################################################################################################################################## # general web socket tools ################################################################################################################################################## def start(self) -> None: """ Start the Chat Client :raises RuntimeError: if already started """ self.logger.debug('starting chat...') if self.__running: raise RuntimeError('already started') if self.username is None: raise RuntimeError('Chat() was not awaited') if not self.twitch.has_required_auth(AuthType.USER, [AuthScope.CHAT_READ]): raise UnauthorizedException('CHAT_READ authscope is required to run a chat bot') self.__startup_complete = False self._closing = False self._ready = False self.__socket_thread = threading.Thread(target=self.__run_socket) self.__running = True self.__socket_thread.start() while not self.__startup_complete: sleep(0.01) self.logger.debug('chat started up!') def stop(self) -> None: """ Stop the Chat Client :raises RuntimeError: if the client is not running """ if not self.__running: raise RuntimeError('not running') self.logger.debug('stopping chat...') self.__startup_complete = False self.__running = False self._ready = False f = asyncio.run_coroutine_threadsafe(self._stop(), self.__socket_loop) f.result() async def _stop(self): await self.__connection.close() await self._session.close() # wait for ssl to close as per aiohttp docs... await asyncio.sleep(0.25) # clean up bot state self.__connection = None self._session = None self.room_cache = {} self._room_join_locks = [] self._room_leave_locks = [] self._closing = True async def __connect(self, is_startup=False): if is_startup: self.logger.debug('connecting...') else: self.logger.debug('reconnecting...') if self.__connection is not None and not self.__connection.closed: await self.__connection.close() retry = 0 need_retry = True if self._session is None: self._session = aiohttp.ClientSession(timeout=self.twitch.session_timeout) while need_retry and retry < len(self.reconnect_delay_steps): need_retry = False try: self.__connection = await self._session.ws_connect(self.connection_url) except Exception: self.logger.warning(f'connection attempt failed, retry in {self.reconnect_delay_steps[retry]}s...') await asyncio.sleep(self.reconnect_delay_steps[retry]) retry += 1 need_retry = True if retry >= len(self.reconnect_delay_steps): raise TwitchBackendException('can\'t connect') async def _keep_loop_alive(self): while not self._closing: await asyncio.sleep(0.1) def __run_socket(self): self.__socket_loop = asyncio.new_event_loop() if self._callback_loop is None: self._callback_loop = self.__socket_loop asyncio.set_event_loop(self.__socket_loop) # startup self.__socket_loop.run_until_complete(self.__connect(is_startup=True)) self.__tasks = [ asyncio.ensure_future(self.__task_receive(), loop=self.__socket_loop), asyncio.ensure_future(self.__task_startup(), loop=self.__socket_loop) ] # keep loop alive self.__socket_loop.run_until_complete(self._keep_loop_alive()) async def _send_message(self, message: str): self.logger.debug(f'> "{message}"') await self.__connection.send_str(message) async def __task_receive(self): receive_timeout = None if self.no_message_reset_time is None else self.no_message_reset_time * 60 try: handlers: Dict[str, Callable] = { 'PING': self._handle_ping, 'PRIVMSG': self._handle_msg, '001': self._handle_ready, 'ROOMSTATE': self._handle_room_state, 'JOIN': self._handle_join, 'USERNOTICE': self._handle_user_notice, 'CLEARMSG': self._handle_clear_msg, 'CAP': self._handle_cap_reply, 'PART': self._handle_part, 'NOTICE': self._handle_notice, 'CLEARCHAT': self._handle_clear_chat, 'WHISPER': self._handle_whisper, 'RECONNECT': self._handle_reconnect, 'USERSTATE': self._handle_user_state } while not self.__connection.closed: try: # At minimum we should receive a PING request just under every 5 minutes message = await self.__connection.receive(timeout=receive_timeout) except asyncio.TimeoutError: self.logger.warning(f"Reached timeout for websocket receive, will attempt a reconnect") if self.__running: try: await self._handle_base_reconnect() except TwitchBackendException: self.logger.exception('Connection to chat websocket lost and unable to reestablish connection!') break else: break if message.type == aiohttp.WSMsgType.TEXT: messages = message.data.split('\r\n') for m in messages: if len(m) == 0: continue self.logger.debug(f'< {m}') parsed = self._parse_irc_message(m) # a message we don't know or don't care about if parsed is None: continue handler = handlers.get(parsed['command']['command']) if handler is not None: asyncio.ensure_future(handler(parsed)) elif message.type == aiohttp.WSMsgType.CLOSED: self.logger.debug('websocket is closing') if self.__running: try: await self._handle_base_reconnect() except TwitchBackendException: self.logger.exception('Connection to chat websocket lost and unable to reestablish connection!') break else: break elif message.type == aiohttp.WSMsgType.ERROR: self.logger.warning('error in websocket: ' + str(self.__connection.exception())) break except CancelledError: # we are closing down! # print('we are closing down!') return async def _handle_base_reconnect(self): await self.__connect(is_startup=False) await self.__task_startup() # noinspection PyUnusedLocal async def _handle_reconnect(self, parsed: dict): self.logger.info('got reconnect request...') await self._handle_base_reconnect() self.logger.info('reconnect completed') async def _handle_whisper(self, parsed: dict): e = WhisperEvent(self, parsed) for handler in self._event_handler.get(ChatEvent.WHISPER, []): t = asyncio.ensure_future(handler(e), loop=self._callback_loop) t.add_done_callback(self._task_callback) async def _handle_clear_chat(self, parsed: dict): e = ClearChatEvent(self, parsed) for handler in self._event_handler.get(ChatEvent.CHAT_CLEARED, []): t = asyncio.ensure_future(handler(e), loop=self._callback_loop) t.add_done_callback(self._task_callback) async def _handle_notice(self, parsed: dict): e = NoticeEvent(self, parsed) for handler in self._event_handler.get(ChatEvent.NOTICE, []): t = asyncio.ensure_future(handler(e), loop=self._callback_loop) t.add_done_callback(self._task_callback) self.logger.debug(f'got NOTICE for channel {parsed["command"]["channel"]}: {parsed["tags"].get("msg-id")}') async def _handle_clear_msg(self, parsed: dict): ev = MessageDeletedEvent(self, parsed) for handler in self._event_handler.get(ChatEvent.MESSAGE_DELETE, []): t = asyncio.ensure_future(handler(ev), loop=self._callback_loop) t.add_done_callback(self._task_callback) async def _handle_cap_reply(self, parsed: dict): self.logger.debug(f'got CAP reply, granted caps: {parsed["parameters"]}') caps = parsed['parameters'].split() if not all([x in caps for x in ['twitch.tv/membership', 'twitch.tv/tags', 'twitch.tv/commands']]): self.logger.warning(f'chat bot did not get all requested capabilities granted, this might result in weird bot behavior!') async def _handle_join(self, parsed: dict): ch = parsed['command']['channel'][1:] nick = parsed['source']['nick'][1:] if ch in self._room_join_locks and nick == self.username: self._room_join_locks.remove(ch) if nick == self.username: e = JoinedEvent(self, ch, nick) for handler in self._event_handler.get(ChatEvent.JOINED, []): t = asyncio.ensure_future(handler(e), loop=self._callback_loop) t.add_done_callback(self._task_callback) else: e = JoinEvent(self, ch, nick) for handler in self._event_handler.get(ChatEvent.JOIN, []): t = asyncio.ensure_future(handler(e), loop=self._callback_loop) t.add_done_callback(self._task_callback) async def _handle_part(self, parsed: dict): ch = parsed['command']['channel'][1:] usr = parsed['source']['nick'][1:] if usr == self.username: if ch in self._room_leave_locks: self._room_leave_locks.remove(ch) room = self.room_cache.pop(ch, None) e = LeftEvent(self, ch, room, usr) for handler in self._event_handler.get(ChatEvent.LEFT, []): t = asyncio.ensure_future(handler(e), loop=self._callback_loop) t.add_done_callback(self._task_callback) else: room = self.room_cache.get(ch) e = LeftEvent(self, ch, room, usr) for handler in self._event_handler.get(ChatEvent.USER_LEFT, []): t = asyncio.ensure_future(handler(e), loop=self._callback_loop) t.add_done_callback(self._task_callback) async def _handle_user_notice(self, parsed: dict): if parsed['tags'].get('msg-id') == 'raid': handlers = self._event_handler.get(ChatEvent.RAID, []) for handler in handlers: asyncio.ensure_future(handler(parsed)) elif parsed['tags'].get('msg-id') in ('sub', 'resub', 'subgift'): sub = ChatSub(self, parsed) for handler in self._event_handler.get(ChatEvent.SUB, []): t = asyncio.ensure_future(handler(sub), loop=self._callback_loop) t.add_done_callback(self._task_callback) async def _handle_room_state(self, parsed: dict): self.logger.debug('got room state event') state = ChatRoom( name=parsed['command']['channel'][1:], is_emote_only=parsed['tags'].get('emote-only') == '1', is_subs_only=parsed['tags'].get('subs-only') == '1', is_followers_only=parsed['tags'].get('followers-only') != '-1', is_unique_only=parsed['tags'].get('r9k') == '1', follower_only_delay=int(parsed['tags'].get('followers-only', '-1')), room_id=parsed['tags'].get('room-id'), slow=int(parsed['tags'].get('slow', '0'))) prev = self.room_cache.get(state.name) # create copy if prev is not None: prev = dataclasses.replace(prev) self.room_cache[state.name] = state dat = RoomStateChangeEvent(self, prev, state) for handler in self._event_handler.get(ChatEvent.ROOM_STATE_CHANGE, []): t = asyncio.ensure_future(handler(dat), loop=self._callback_loop) t.add_done_callback(self._task_callback) async def _handle_user_state(self, parsed: dict): self.logger.debug('got user state event') is_broadcaster = False if parsed['tags'].get('badges') is not None: is_broadcaster = parsed['tags']['badges'].get('broadcaster') is not None self._mod_status_cache[parsed['command']['channel'][1:]] = 'mod' if parsed['tags']['mod'] == '1' or is_broadcaster else 'user' self._subscriber_status_cache[parsed['command']['channel'][1:]] = 'sub' if parsed['tags']['subscriber'] == '1' else 'non-sub' async def _handle_ping(self, parsed: dict): self.logger.debug('got PING') await self._send_message('PONG ' + parsed['parameters']) # noinspection PyUnusedLocal async def _handle_ready(self, parsed: dict): self.logger.debug('got ready event') dat = EventData(self) was_ready = self._ready self._ready = True if self._join_target is not None and len(self._join_target) > 0: _failed = await self.join_room(self._join_target) if len(_failed) > 0: self.logger.warning(f'failed to join the following channel of the initial following list: {", ".join(_failed)}') else: self.logger.info('done joining initial channels') if not was_ready: for h in self._event_handler.get(ChatEvent.READY, []): t = asyncio.ensure_future(h(dat), loop=self._callback_loop) t.add_done_callback(self._task_callback) async def _handle_msg(self, parsed: dict): async def _can_execute_command(_com: ChatCommand, _name: str) -> bool: for mid in self._command_middleware + self._command_specific_middleware.get(_name, []): if not await mid.can_execute(command): if mid.execute_blocked_handler is not None: await mid.execute_blocked_handler(_com) elif self.default_command_execution_blocked_handler is not None: await self.default_command_execution_blocked_handler(_com) return False return True self.logger.debug('got new message, call handler') if parsed['command'].get('bot_command') is not None: command_name = parsed['command'].get('bot_command').lower() handler = self._command_handler.get(command_name) if handler is not None: command = ChatCommand(self, parsed) # check middleware if await _can_execute_command(command, command_name): t = asyncio.ensure_future(handler(command), loop=self._callback_loop) t.add_done_callback(self._task_callback) for _mid in self._command_middleware + self._command_specific_middleware.get(command_name, []): await _mid.was_executed(command) else: if self.log_no_registered_command_handler: self.logger.info(f'no handler registered for command "{command_name}"') handler = self._event_handler.get(ChatEvent.MESSAGE, []) message = ChatMessage(self, parsed) for h in handler: t = asyncio.ensure_future(h(message), loop=self._callback_loop) t.add_done_callback(self._task_callback) async def __task_startup(self): await self._send_message('CAP REQ :twitch.tv/membership twitch.tv/tags twitch.tv/commands') await self._send_message(f'PASS oauth:{await self.twitch.get_refreshed_user_auth_token()}') await self._send_message(f'NICK {self.username}') self.__startup_complete = True def _get_message_bucket(self, channel) -> RateLimitBucket: bucket = self._send_buckets.get(channel) if bucket is None: bucket = RateLimitBucket(30, 20, channel, self.logger) self._send_buckets[channel] = bucket target_size = RATE_LIMIT_SIZES[self._mod_status_cache.get(channel, 'user')] if bucket.bucket_size != target_size: bucket.bucket_size = target_size return bucket ################################################################################################################################################## # user functions ################################################################################################################################################## def set_prefix(self, prefix: str): """Sets a command prefix. The default prefix is !, the prefix can not start with / or . :param prefix: the new prefix to use for command parsing :raises ValueError: when the given prefix is None or starts with / or . """ if prefix is None or prefix[0] in ('/', '.'): raise ValueError('Prefix starting with / or . are reserved for twitch internal use') self._prefix = prefix def set_channel_prefix(self, prefix: str, channel: Union[CHATROOM_TYPE, List[CHATROOM_TYPE]]): """Sets a command prefix for the given channel or channels The default channel prefix is either ! or the one set by :const:`~twitchAPI.chat.Chat.set_prefix()`, the prefix can not start with / or . :param prefix: the new prefix to use for commands in the given channels :param channel: the channel or channels you want the given command prefix to be used in :raises ValueError: when the given prefix is None or starts with / or . """ if prefix is None or prefix[0] in ('/', '.'): raise ValueError('Prefix starting with / or . are reserved for twitch internal use') if not isinstance(channel, List): channel = [channel] for ch in channel: if isinstance(ch, ChatRoom): ch = ch.name self._channel_command_prefix[ch] = prefix def reset_channel_prefix(self, channel: Union[CHATROOM_TYPE, List[CHATROOM_TYPE]]): """Resets the custom command prefix set by :const:`~twitchAPI.chat.Chat.set_channel_prefix()` back to the global one. :param channel: The channel or channels you want to reset the channel command prefix for """ if not isinstance(channel, List): channel = [channel] for ch in channel: if isinstance(ch, ChatRoom): ch = ch.name self._channel_command_prefix.pop(ch, None) def register_command(self, name: str, handler: COMMAND_CALLBACK_TYPE, command_middleware: Optional[List['BaseCommandMiddleware']] = None) -> bool: """Register a command :param name: the name of the command :param handler: The event handler :param command_middleware: a optional list of middleware to use just for this command :raises ValueError: if handler is not a coroutine""" if not asyncio.iscoroutinefunction(handler): raise ValueError('handler needs to be a async function which takes one parameter') name = name.lower() if self._command_handler.get(name) is not None: return False self._command_handler[name] = handler if command_middleware is not None: self._command_specific_middleware[name] = command_middleware return True def unregister_command(self, name: str) -> bool: """Unregister a already registered command. :param name: the name of the command to unregister :return: True if the command was unregistered, otherwise false """ name = name.lower() if self._command_handler.get(name) is None: return False self._command_handler.pop(name, None) return True def register_event(self, event: ChatEvent, handler: EVENT_CALLBACK_TYPE): """Register a event handler :param event: The Event you want to register the handler to :param handler: The handler you want to register. :raises ValueError: if handler is not a coroutine""" if not asyncio.iscoroutinefunction(handler): raise ValueError('handler needs to be a async function which takes one parameter') if self._event_handler.get(event) is None: self._event_handler[event] = [handler] else: self._event_handler[event].append(handler) def unregister_event(self, event: ChatEvent, handler: EVENT_CALLBACK_TYPE) -> bool: """Unregister a handler from a event :param event: The Event you want to unregister your handler from :param handler: The handler you want to unregister :return: Returns true when the handler was removed from the event, otherwise false """ if self._event_handler.get(event) is None or handler not in self._event_handler.get(event): return False self._event_handler[event].remove(handler) return True def is_connected(self) -> bool: """Returns your current connection status.""" if self.__connection is None: return False return not self.__connection.closed def is_ready(self) -> bool: """Returns True if the chat bot is ready to join channels and/or receive events""" return self._ready def is_mod(self, room: CHATROOM_TYPE) -> bool: """Check if chat bot is a mod in a channel :param room: The chat room you want to check if bot is a mod in. This can either be a instance of :const:`~twitchAPI.type.ChatRoom` or a string with the room name (either with leading # or without) :return: Returns True if chat bot is a mod """ if isinstance(room, ChatRoom): room = room.name if room is None or len(room) == 0: raise ValueError('please specify a room') if room[0] == '#': room = room[1:] return self._mod_status_cache.get(room.lower(), 'user') == 'mod' def is_subscriber(self, room: CHATROOM_TYPE) -> bool: """Check if chat bot is a subscriber in a channel :param room: The chat room you want to check if bot is a subscriber in. This can either be a instance of :const:`~twitchAPI.type.ChatRoom` or a string with the room name (either with leading # or without) :return: Returns True if chat bot is a subscriber """ if isinstance(room, ChatRoom): room = room.name if room is None or len(room) == 0: raise ValueError('please specify a room') if room[0] == '#': room = room[1:] return self._subscriber_status_cache.get(room.lower(), 'user') == 'sub' def is_in_room(self, room: CHATROOM_TYPE) -> bool: """Check if the bot is currently in the given chat room :param room: The chat room you want to check This can either be a instance of :const:`~twitchAPI.type.ChatRoom` or a string with the room name (either with leading # or without) """ if isinstance(room, ChatRoom): room = room.name if room is None or len(room) == 0: raise ValueError('please specify a room') if room[0] == '#': room = room[1:] return self.room_cache.get(room.lower()) is not None async def join_room(self, chat_rooms: Union[List[str], str]): """ join one or more chat rooms\n Will only exit once all given chat rooms where successfully joined or :const:`twitchAPI.chat.Chat.join_timeout` run out. :param chat_rooms: the Room or rooms you want to join :returns: list of channels that could not be joined """ if isinstance(chat_rooms, str): chat_rooms = [chat_rooms] target = [c[1:].lower() if c[0] == '#' else c.lower() for c in chat_rooms] for r in target: self._room_join_locks.append(r) if len(target) > self._join_bucket.left(): # we want to join more than the current bucket has left, join slowly one after another # TODO we could join the current remaining bucket size in blocks for r in target: await self._join_bucket.put() await self._send_message(f'JOIN #{r}') else: # enough space in the current bucket left, join all at once await self._join_bucket.put(len(target)) await self._send_message(f'JOIN {",".join([f"#{x}" for x in target])}') # wait for us to join all rooms timeout = datetime.datetime.now() + datetime.timedelta(seconds=self.join_timeout) while any([r in self._room_join_locks for r in target]) and timeout > datetime.datetime.now(): await asyncio.sleep(0.01) failed_to_join = [r for r in self._room_join_locks if r in target] self._join_target.extend([x for x in target if x not in failed_to_join]) # deduplicate join target self._join_target = list(set(self._join_target)) for r in failed_to_join: self._room_join_locks.remove(r) return failed_to_join async def send_raw_irc_message(self, message: str): """Send a raw IRC message :param message: the message to send :raises ValueError: if bot is not ready """ if not self.is_ready(): raise ValueError('can\'t send message: bot not ready') while not self.is_connected(): await asyncio.sleep(0.1) if message is None or len(message) == 0: raise ValueError('message must be a non empty string') await self._send_message(message) async def send_message(self, room: CHATROOM_TYPE, text: str): """Send a message to the given channel Please note that you first need to join a channel before you can send a message to it. :param room: The chat room you want to send the message to. This can either be a instance of :const:`~twitchAPI.type.ChatRoom` or a string with the room name (either with leading # or without) :param text: The text you want to send :raises ValueError: if message is empty or room is not given :raises ValueError: if bot is not ready """ if not self.is_ready(): raise ValueError('can\'t send message: bot not ready') while not self.is_connected(): await asyncio.sleep(0.1) if isinstance(room, ChatRoom): room = room.name if room is None or len(room) == 0: raise ValueError('please specify a room to post to') if text is None or len(text) == 0: raise ValueError('you can\'t send a empty message') if room[0] != '#': room = f'#{room}'.lower() bucket = self._get_message_bucket(room[1:]) await bucket.put() await self._send_message(f'PRIVMSG {room} :{text}') async def leave_room(self, chat_rooms: Union[List[str], str]): """leave one or more chat rooms\n Will only exit once all given chat rooms where successfully left :param chat_rooms: The room or rooms you want to leave""" if isinstance(chat_rooms, str): chat_rooms = [chat_rooms] room_str = ','.join([f'#{c}'.lower() if c[0] != '#' else c.lower() for c in chat_rooms]) target = [c[1:].lower() if c[0] == '#' else c.lower() for c in chat_rooms] for r in target: self._room_leave_locks.append(r) await self._send_message(f'PART {room_str}') for x in target: if x in self._join_target: self._join_target.remove(x) # wait to leave all rooms while any([r in self._room_leave_locks for r in target]): await asyncio.sleep(0.01) def register_command_middleware(self, mid: 'BaseCommandMiddleware'): """Adds the given command middleware as a general middleware""" if mid not in self._command_middleware: self._command_middleware.append(mid) def unregister_command_middleware(self, mid: 'BaseCommandMiddleware'): """Removes the given command middleware from the general list""" if mid in self._command_middleware: self._command_middleware.remove(mid) Teekeks-pyTwitchAPI-0d97664/twitchAPI/chat/middleware.py000066400000000000000000000235611463733066200230200ustar00rootroot00000000000000# Copyright (c) 2023. Lena "Teekeks" During """ Chat Command Middleware ----------------------- A selection of preimplemented chat command middleware. .. note:: See :doc:`/tutorial/chat-use-middleware` for a more detailed walkthough on how to use these. Available Middleware ==================== .. list-table:: :header-rows: 1 * - Middleware - Description * - :const:`~twitchAPI.chat.middleware.ChannelRestriction` - Filters in which channels a command can be executed in. * - :const:`~twitchAPI.chat.middleware.UserRestriction` - Filters which users can execute a command. * - :const:`~twitchAPI.chat.middleware.StreamerOnly` - Restricts the use of commands to only the streamer in their channel. * - :const:`~twitchAPI.chat.middleware.ChannelCommandCooldown` - Restricts a command to only be executed once every :const:`cooldown_seconds` in a channel regardless of user. * - :const:`~twitchAPI.chat.middleware.ChannelUserCommandCooldown` - Restricts a command to be only executed once every :const:`cooldown_seconds` in a channel by a user. * - :const:`~twitchAPI.chat.middleware.GlobalCommandCooldown` - Restricts a command to be only executed once every :const:`cooldown_seconds` in any channel. Class Documentation =================== """ from abc import ABC, abstractmethod from datetime import datetime from typing import Optional, List, TYPE_CHECKING, Callable, Awaitable, Dict if TYPE_CHECKING: from . import ChatCommand __all__ = ['BaseCommandMiddleware', 'ChannelRestriction', 'UserRestriction', 'StreamerOnly', 'ChannelCommandCooldown', 'ChannelUserCommandCooldown', 'GlobalCommandCooldown'] class BaseCommandMiddleware(ABC): """The base for chat command middleware, extend from this when implementing your own""" execute_blocked_handler: Optional[Callable[['ChatCommand'], Awaitable[None]]] = None """If set, this handler will be called should :const:`~twitchAPI.chat.middleware.BaseCommandMiddleware.can_execute()` fail.""" @abstractmethod async def can_execute(self, command: 'ChatCommand') -> bool: """ return :code:`True` if the given command should execute, otherwise :code:`False` :param command: The command to check if it should be executed""" pass @abstractmethod async def was_executed(self, command: 'ChatCommand'): """Will be called when a command was executed, use to update internal state""" pass class ChannelRestriction(BaseCommandMiddleware): """Filters in which channels a command can be executed in""" def __init__(self, allowed_channel: Optional[List[str]] = None, denied_channel: Optional[List[str]] = None, execute_blocked_handler: Optional[Callable[['ChatCommand'], Awaitable[None]]] = None): """ :param allowed_channel: if provided, the command can only be used in channels on this list :param denied_channel: if provided, the command can't be used in channels on this list :param execute_blocked_handler: optional specific handler for when the execution is blocked """ self.execute_blocked_handler = execute_blocked_handler self.allowed = allowed_channel if allowed_channel is not None else [] self.denied = denied_channel if denied_channel is not None else [] async def can_execute(self, command: 'ChatCommand') -> bool: if len(self.allowed) > 0: if command.room.name not in self.allowed: return False return command.room.name not in self.denied async def was_executed(self, command: 'ChatCommand'): pass class UserRestriction(BaseCommandMiddleware): """Filters which users can execute a command""" def __init__(self, allowed_users: Optional[List[str]] = None, denied_users: Optional[List[str]] = None, execute_blocked_handler: Optional[Callable[['ChatCommand'], Awaitable[None]]] = None): """ :param allowed_users: if provided, the command can only be used by one of the provided users :param denied_users: if provided, the command can not be used by any of the provided users :param execute_blocked_handler: optional specific handler for when the execution is blocked """ self.execute_blocked_handler = execute_blocked_handler self.allowed = allowed_users if allowed_users is not None else [] self.denied = denied_users if denied_users is not None else [] async def can_execute(self, command: 'ChatCommand') -> bool: if len(self.allowed) > 0: if command.user.name not in self.allowed: return False return command.user.name not in self.denied async def was_executed(self, command: 'ChatCommand'): pass class StreamerOnly(BaseCommandMiddleware): """Restricts the use of commands to only the streamer in their channel""" def __init__(self, execute_blocked_handler: Optional[Callable[['ChatCommand'], Awaitable[None]]] = None): """ :param execute_blocked_handler: optional specific handler for when the execution is blocked """ self.execute_blocked_handler = execute_blocked_handler async def can_execute(self, command: 'ChatCommand') -> bool: return command.room.name == command.user.name async def was_executed(self, command: 'ChatCommand'): pass class ChannelCommandCooldown(BaseCommandMiddleware): """Restricts a command to only be executed once every :const:`cooldown_seconds` in a channel regardless of user.""" # command -> channel -> datetime _last_executed: Dict[str, Dict[str, datetime]] = {} def __init__(self, cooldown_seconds: int, execute_blocked_handler: Optional[Callable[['ChatCommand'], Awaitable[None]]] = None): """ :param cooldown_seconds: time in seconds a command should not be used again :param execute_blocked_handler: optional specific handler for when the execution is blocked """ self.execute_blocked_handler = execute_blocked_handler self.cooldown = cooldown_seconds async def can_execute(self, command: 'ChatCommand') -> bool: if self._last_executed.get(command.name) is None: return True last_executed = self._last_executed[command.name].get(command.room.name) if last_executed is None: return True since = (datetime.now() - last_executed).total_seconds() return since >= self.cooldown async def was_executed(self, command: 'ChatCommand'): if self._last_executed.get(command.name) is None: self._last_executed[command.name] = {} self._last_executed[command.name][command.room.name] = datetime.now() return self._last_executed[command.name][command.room.name] = datetime.now() class ChannelUserCommandCooldown(BaseCommandMiddleware): """Restricts a command to be only executed once every :const:`cooldown_seconds` in a channel by a user.""" # command -> channel -> user -> datetime _last_executed: Dict[str, Dict[str, Dict[str, datetime]]] = {} def __init__(self, cooldown_seconds: int, execute_blocked_handler: Optional[Callable[['ChatCommand'], Awaitable[None]]] = None): """ :param cooldown_seconds: time in seconds a command should not be used again :param execute_blocked_handler: optional specific handler for when the execution is blocked """ self.execute_blocked_handler = execute_blocked_handler self.cooldown = cooldown_seconds async def can_execute(self, command: 'ChatCommand') -> bool: if self._last_executed.get(command.name) is None: return True if self._last_executed[command.name].get(command.room.name) is None: return True last_executed = self._last_executed[command.name][command.room.name].get(command.user.name) if last_executed is None: return True since = (datetime.now() - last_executed).total_seconds() return since >= self.cooldown async def was_executed(self, command: 'ChatCommand'): if self._last_executed.get(command.name) is None: self._last_executed[command.name] = {} self._last_executed[command.name][command.room.name] = {} self._last_executed[command.name][command.room.name][command.user.name] = datetime.now() return if self._last_executed[command.name].get(command.room.name) is None: self._last_executed[command.name][command.room.name] = {} self._last_executed[command.name][command.room.name][command.user.name] = datetime.now() return self._last_executed[command.name][command.room.name][command.user.name] = datetime.now() class GlobalCommandCooldown(BaseCommandMiddleware): """Restricts a command to be only executed once every :const:`cooldown_seconds` in any channel""" # command -> datetime _last_executed: Dict[str, datetime] = {} def __init__(self, cooldown_seconds: int, execute_blocked_handler: Optional[Callable[['ChatCommand'], Awaitable[None]]] = None): """ :param cooldown_seconds: time in seconds a command should not be used again :param execute_blocked_handler: optional specific handler for when the execution is blocked """ self.execute_blocked_handler = execute_blocked_handler self.cooldown = cooldown_seconds async def can_execute(self, command: 'ChatCommand') -> bool: if self._last_executed.get(command.name) is None: return True since = (datetime.now() - self._last_executed[command.name]).total_seconds() return since >= self.cooldown async def was_executed(self, command: 'ChatCommand'): self._last_executed[command.name] = datetime.now() Teekeks-pyTwitchAPI-0d97664/twitchAPI/eventsub/000077500000000000000000000000001463733066200212365ustar00rootroot00000000000000Teekeks-pyTwitchAPI-0d97664/twitchAPI/eventsub/__init__.py000066400000000000000000000366461463733066200233660ustar00rootroot00000000000000# Copyright (c) 2023. Lena "Teekeks" During """ EventSub -------- EventSub lets you listen for events that happen on Twitch. All available EventSub clients runs in their own thread, calling the given callback function whenever an event happens. Look at :ref:`eventsub-available-topics` to find the topics you are interested in. Available Transports ==================== EventSub is available with different types of transports, used for different applications. .. list-table:: :header-rows: 1 * - Transport Method - Use Case - Auth Type * - :doc:`twitchAPI.eventsub.webhook` - Server / Multi User - App Authentication * - :doc:`twitchAPI.eventsub.websocket` - Client / Single User - User Authentication .. _eventsub-available-topics: Available Topics and Callback Payloads ====================================== List of available EventSub Topics. The Callback Payload is the type of the parameter passed to the callback function you specified in :const:`listen_`. .. list-table:: :header-rows: 1 * - Topic - Subscription Function & Callback Payload - Description * - **Channel Update** v1 - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_update()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelUpdateEvent` - A broadcaster updates their channel properties e.g., category, title, mature flag, broadcast, or language. * - **Channel Update** v2 - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_update_v2()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelUpdateEvent` - A broadcaster updates their channel properties e.g., category, title, content classification labels, broadcast, or language. * - **Channel Follow** v2 - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_follow_v2()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelFollowEvent` - A specified channel receives a follow. * - **Channel Subscribe** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_subscribe()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelSubscribeEvent` - A notification when a specified channel receives a subscriber. This does not include resubscribes. * - **Channel Subscription End** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_subscription_end()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelSubscriptionEndEvent` - A notification when a subscription to the specified channel ends. * - **Channel Subscription Gift** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_subscription_gift()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelSubscriptionGiftEvent` - A notification when a viewer gives a gift subscription to one or more users in the specified channel. * - **Channel Subscription Message** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_subscription_message()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelSubscriptionMessageEvent` - A notification when a user sends a resubscription chat message in a specific channel. * - **Channel Cheer** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_cheer()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelCheerEvent` - A user cheers on the specified channel. * - **Channel Raid** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_raid()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelRaidEvent` - A broadcaster raids another broadcaster’s channel. * - **Channel Ban** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_ban()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelBanEvent` - A viewer is banned from the specified channel. * - **Channel Unban** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_unban()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelUnbanEvent` - A viewer is unbanned from the specified channel. * - **Channel Moderator Add** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_moderator_add()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelModeratorAddEvent` - Moderator privileges were added to a user on a specified channel. * - **Channel Moderator Remove** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_moderator_remove()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelModeratorRemoveEvent` - Moderator privileges were removed from a user on a specified channel. * - **Channel Points Custom Reward Add** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_points_custom_reward_add()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelPointsCustomRewardAddEvent` - A custom channel points reward has been created for the specified channel. * - **Channel Points Custom Reward Update** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_points_custom_reward_update()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelPointsCustomRewardUpdateEvent` - A custom channel points reward has been updated for the specified channel. * - **Channel Points Custom Reward Remove** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_points_custom_reward_remove()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelPointsCustomRewardRemoveEvent` - A custom channel points reward has been removed from the specified channel. * - **Channel Points Custom Reward Redemption Add** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_points_custom_reward_redemption_add()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelPointsCustomRewardRedemptionAddEvent` - A viewer has redeemed a custom channel points reward on the specified channel. * - **Channel Points Custom Reward Redemption Update** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_points_custom_reward_redemption_update()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelPointsCustomRewardRedemptionUpdateEvent` - A redemption of a channel points custom reward has been updated for the specified channel. * - **Channel Poll Begin** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_poll_begin()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelPollBeginEvent` - A poll started on a specified channel. * - **Channel Poll Progress** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_poll_progress()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelPollProgressEvent` - Users respond to a poll on a specified channel. * - **Channel Poll End** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_poll_end()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelPollEndEvent` - A poll ended on a specified channel. * - **Channel Prediction Begin** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_prediction_begin()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelPredictionEvent` - A Prediction started on a specified channel. * - **Channel Prediction Progress** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_prediction_progress()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelPredictionEvent` - Users participated in a Prediction on a specified channel. * - **Channel Prediction Lock** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_prediction_lock()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelPredictionEvent` - A Prediction was locked on a specified channel. * - **Channel Prediction End** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_prediction_end()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelPredictionEndEvent` - A Prediction ended on a specified channel. * - **Drop Entitlement Grant** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_drop_entitlement_grant()` |br| Payload: :const:`~twitchAPI.object.eventsub.DropEntitlementGrantEvent` - An entitlement for a Drop is granted to a user. * - **Extension Bits Transaction Create** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_extension_bits_transaction_create()` |br| Payload: :const:`~twitchAPI.object.eventsub.ExtensionBitsTransactionCreateEvent` - A Bits transaction occurred for a specified Twitch Extension. * - **Goal Begin** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_goal_begin()` |br| Payload: :const:`~twitchAPI.object.eventsub.GoalEvent` - A goal begins on the specified channel. * - **Goal Progress** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_goal_progress()` |br| Payload: :const:`~twitchAPI.object.eventsub.GoalEvent` - A goal makes progress on the specified channel. * - **Goal End** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_goal_end()` |br| Payload: :const:`~twitchAPI.object.eventsub.GoalEvent` - A goal ends on the specified channel. * - **Hype Train Begin** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_hype_train_begin()` |br| Payload: :const:`~twitchAPI.object.eventsub.HypeTrainEvent` - A Hype Train begins on the specified channel. * - **Hype Train Progress** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_hype_train_progress()` |br| Payload: :const:`~twitchAPI.object.eventsub.HypeTrainEvent` - A Hype Train makes progress on the specified channel. * - **Hype Train End** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_hype_train_end()` |br| Payload: :const:`~twitchAPI.object.eventsub.HypeTrainEvent` - A Hype Train ends on the specified channel. * - **Stream Online** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_stream_online()` |br| Payload: :const:`~twitchAPI.object.eventsub.StreamOnlineEvent` - The specified broadcaster starts a stream. * - **Stream Offline** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_stream_offline()` |br| Payload: :const:`~twitchAPI.object.eventsub.StreamOfflineEvent` - The specified broadcaster stops a stream. * - **User Authorization Grant** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_user_authorization_grant()` |br| Payload: :const:`~twitchAPI.object.eventsub.UserAuthorizationGrantEvent` - A user’s authorization has been granted to your client id. * - **User Authorization Revoke** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_user_authorization_revoke()` |br| Payload: :const:`~twitchAPI.object.eventsub.UserAuthorizationRevokeEvent` - A user’s authorization has been revoked for your client id. * - **User Update** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_user_update()` |br| Payload: :const:`~twitchAPI.object.eventsub.UserUpdateEvent` - A user has updated their account. * - **Channel Shield Mode Begin** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_shield_mode_begin()` |br| Payload: :const:`~twitchAPI.object.eventsub.ShieldModeEvent` - Sends a notification when the broadcaster activates Shield Mode. * - **Channel Shield Mode End** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_shield_mode_end()` |br| Payload: :const:`~twitchAPI.object.eventsub.ShieldModeEvent` - Sends a notification when the broadcaster deactivates Shield Mode. * - **Channel Charity Campaign Start** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_charity_campaign_start()` |br| Payload: :const:`~twitchAPI.object.eventsub.CharityCampaignStartEvent` - Sends a notification when the broadcaster starts a charity campaign. * - **Channel Charity Campaign Progress** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_charity_campaign_progress()` |br| Payload: :const:`~twitchAPI.object.eventsub.CharityCampaignProgressEvent` - Sends notifications when progress is made towards the campaign’s goal or when the broadcaster changes the fundraising goal. * - **Channel Charity Campaign Stop** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_charity_campaign_stop()` |br| Payload: :const:`~twitchAPI.object.eventsub.CharityCampaignStopEvent` - Sends a notification when the broadcaster stops a charity campaign. * - **Channel Charity Campaign Donate** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_charity_campaign_donate()` |br| Payload: :const:`~twitchAPI.object.eventsub.CharityDonationEvent` - Sends a notification when a user donates to the broadcaster’s charity campaign. * - **Channel Shoutout Create** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_shoutout_create()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelShoutoutCreateEvent` - Sends a notification when the specified broadcaster sends a Shoutout. * - **Channel Shoutout Receive** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_shoutout_receive()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelShoutoutReceiveEvent` - Sends a notification when the specified broadcaster receives a Shoutout. * - **Channel Chat Clear** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_clear()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelChatClearEvent` - A moderator or bot has cleared all messages from the chat room. * - **Channel Chat Clear User Messages** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_clear_user_messages()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelChatClearUserMessagesEvent` - A moderator or bot has cleared all messages from a specific user. * - **Channel Chat Message Delete** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_message_delete()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelChatMessageDeleteEvent` - A moderator has removed a specific message. * - **Channel Chat Notification** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_notification()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelChatNotificationEvent` - A notification for when an event that appears in chat has occurred. * - **Channel Chat Message** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_chat_message()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelChatMessageEvent` - Any user sends a message to a specific chat room. * - **Channel Ad Break Begin** - Function: :const:`~twitchAPI.eventsub.base.EventSubBase.listen_channel_ad_break_begin()` |br| Payload: :const:`~twitchAPI.object.eventsub.ChannelAdBreakBeginEvent` - A midroll commercial break has started running. """ Teekeks-pyTwitchAPI-0d97664/twitchAPI/eventsub/base.py000066400000000000000000002435771463733066200225440ustar00rootroot00000000000000# Copyright (c) 2021. Lena "Teekeks" During """ Base EventSub Client -------------------- .. note:: This is the base class used for all EventSub Transport implementations. See :doc:`twitchAPI.eventsub` for a list of all available Transports. ******************* Class Documentation ******************* """ from twitchAPI.object.eventsub import (ChannelPollBeginEvent, ChannelUpdateEvent, ChannelFollowEvent, ChannelSubscribeEvent, ChannelSubscriptionEndEvent, ChannelSubscriptionGiftEvent, ChannelSubscriptionMessageEvent, ChannelCheerEvent, ChannelRaidEvent, ChannelBanEvent, ChannelUnbanEvent, ChannelModeratorAddEvent, ChannelModeratorRemoveEvent, ChannelPointsCustomRewardAddEvent, ChannelPointsCustomRewardUpdateEvent, ChannelPointsCustomRewardRemoveEvent, ChannelPointsCustomRewardRedemptionAddEvent, ChannelPointsCustomRewardRedemptionUpdateEvent, ChannelPollProgressEvent, ChannelPollEndEvent, ChannelPredictionEvent, ChannelPredictionEndEvent, DropEntitlementGrantEvent, ExtensionBitsTransactionCreateEvent, GoalEvent, HypeTrainEvent, HypeTrainEndEvent, StreamOnlineEvent, StreamOfflineEvent, UserAuthorizationGrantEvent, UserAuthorizationRevokeEvent, UserUpdateEvent, ShieldModeEvent, CharityCampaignStartEvent, CharityCampaignProgressEvent, CharityCampaignStopEvent, CharityDonationEvent, ChannelShoutoutCreateEvent, ChannelShoutoutReceiveEvent, ChannelChatClearEvent, ChannelChatClearUserMessagesEvent, ChannelChatMessageDeleteEvent, ChannelChatNotificationEvent, ChannelAdBreakBeginEvent, ChannelChatMessageEvent) from twitchAPI.helper import remove_none_values from twitchAPI.type import TwitchAPIException import asyncio from logging import getLogger, Logger from twitchAPI.twitch import Twitch from abc import ABC, abstractmethod from typing import Union, Callable, Optional, Awaitable __all__ = ['EventSubBase'] class EventSubBase(ABC): """EventSub integration for the Twitch Helix API.""" def __init__(self, twitch: Twitch): """ :param twitch: a app authenticated instance of :const:`~twitchAPI.twitch.Twitch` """ self._twitch: Twitch = twitch self.logger: Logger = getLogger('twitchAPI.eventsub') """The logger used for EventSub related log messages""" self._callbacks = {} @abstractmethod def start(self): """Starts the EventSub client :rtype: None :raises RuntimeError: if EventSub is already running """ @abstractmethod async def stop(self): """Stops the EventSub client This also unsubscribes from all known subscriptions if unsubscribe_on_stop is True :rtype: None """ @abstractmethod def _get_transport(self): pass # ================================================================================================================== # HELPER # ================================================================================================================== @abstractmethod async def _build_request_header(self): pass async def _api_post_request(self, session, url: str, data: Union[dict, None] = None): headers = await self._build_request_header() return await session.post(url, headers=headers, json=data) def _add_callback(self, c_id: str, callback, event): self._callbacks[c_id] = {'id': c_id, 'callback': callback, 'active': False, 'event': event} async def _activate_callback(self, c_id: str): if c_id not in self._callbacks: self.logger.debug(f'callback for {c_id} arrived before confirmation, waiting...') while c_id not in self._callbacks: await asyncio.sleep(0.1) self._callbacks[c_id]['active'] = True @abstractmethod async def _subscribe(self, sub_type: str, sub_version: str, condition: dict, callback, event, is_batching_enabled: Optional[bool] = None) -> str: pass # ================================================================================================================== # HANDLERS # ================================================================================================================== async def unsubscribe_all(self): """Unsubscribe from all subscriptions""" ret = await self._twitch.get_eventsub_subscriptions() async for d in ret: try: await self._twitch.delete_eventsub_subscription(d.id) except TwitchAPIException as e: self.logger.warning(f'failed to unsubscribe from event {d.id}: {str(e)}') self._callbacks.clear() async def unsubscribe_all_known(self): """Unsubscribe from all subscriptions known to this client.""" for key, value in self._callbacks.items(): self.logger.debug(f'unsubscribe from event {key}') try: await self._twitch.delete_eventsub_subscription(key) except TwitchAPIException as e: self.logger.warning(f'failed to unsubscribe from event {key}: {str(e)}') self._callbacks.clear() @abstractmethod async def _unsubscribe_hook(self, topic_id: str) -> bool: pass async def unsubscribe_topic(self, topic_id: str) -> bool: """Unsubscribe from a specific topic.""" try: await self._twitch.delete_eventsub_subscription(topic_id) self._callbacks.pop(topic_id, None) return await self._unsubscribe_hook(topic_id) except TwitchAPIException as e: self.logger.warning(f'failed to unsubscribe from {topic_id}: {str(e)}') return False async def listen_channel_update(self, broadcaster_user_id: str, callback: Callable[[ChannelUpdateEvent], Awaitable[None]]) -> str: """A broadcaster updates their channel properties e.g., category, title, mature flag, broadcast, or language. No Authentication required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelupdate :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.update', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelUpdateEvent) async def listen_channel_update_v2(self, broadcaster_user_id: str, callback: Callable[[ChannelUpdateEvent], Awaitable[None]]) -> str: """A broadcaster updates their channel properties e.g., category, title, content classification labels or language. No Authentication required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelupdate :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.update', '2', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelUpdateEvent) async def listen_channel_follow_v2(self, broadcaster_user_id: str, moderator_user_id: str, callback: Callable[[ChannelFollowEvent], Awaitable[None]]) -> str: """A specified channel receives a follow. User Authentication with :const:`~twitchAPI.type.AuthScope.MODERATOR_READ_FOLLOWERS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelfollow :param broadcaster_user_id: the id of the user you want to listen to :param moderator_user_id: The ID of the moderator of the channel you want to get follow notifications for. :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.follow', '2', {'broadcaster_user_id': broadcaster_user_id, 'moderator_user_id': moderator_user_id}, callback, ChannelFollowEvent) async def listen_channel_subscribe(self, broadcaster_user_id: str, callback: Callable[[ChannelSubscribeEvent], Awaitable[None]]) -> str: """A notification when a specified channel receives a subscriber. This does not include resubscribes. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_SUBSCRIPTIONS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelsubscribe :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.subscribe', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelSubscribeEvent) async def listen_channel_subscription_end(self, broadcaster_user_id: str, callback: Callable[[ChannelSubscriptionEndEvent], Awaitable[None]]) -> str: """A notification when a subscription to the specified channel ends. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_SUBSCRIPTIONS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelsubscriptionend :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.subscription.end', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelSubscriptionEndEvent) async def listen_channel_subscription_gift(self, broadcaster_user_id: str, callback: Callable[[ChannelSubscriptionGiftEvent], Awaitable[None]]) -> str: """A notification when a viewer gives a gift subscription to one or more users in the specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_SUBSCRIPTIONS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelsubscriptiongift :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.subscription.gift', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelSubscriptionGiftEvent) async def listen_channel_subscription_message(self, broadcaster_user_id: str, callback: Callable[[ChannelSubscriptionMessageEvent], Awaitable[None]]) -> str: """A notification when a user sends a resubscription chat message in a specific channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_SUBSCRIPTIONS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelsubscriptionmessage :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.subscription.message', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelSubscriptionMessageEvent) async def listen_channel_cheer(self, broadcaster_user_id: str, callback: Callable[[ChannelCheerEvent], Awaitable[None]]) -> str: """A user cheers on the specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.BITS_READ` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelcheer :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.cheer', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelCheerEvent) async def listen_channel_raid(self, callback: Callable[[ChannelRaidEvent], Awaitable[None]], to_broadcaster_user_id: Optional[str] = None, from_broadcaster_user_id: Optional[str] = None) -> str: """A broadcaster raids another broadcaster’s channel. No authorization required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelraid :param from_broadcaster_user_id: The broadcaster user ID that created the channel raid you want to get notifications for. :param to_broadcaster_user_id: The broadcaster user ID that received the channel raid you want to get notifications for. :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.raid', '1', remove_none_values({ 'from_broadcaster_user_id': from_broadcaster_user_id, 'to_broadcaster_user_id': to_broadcaster_user_id}), callback, ChannelRaidEvent) async def listen_channel_ban(self, broadcaster_user_id: str, callback: Callable[[ChannelBanEvent], Awaitable[None]]) -> str: """A viewer is banned from the specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MODERATE` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelban :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.ban', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelBanEvent) async def listen_channel_unban(self, broadcaster_user_id: str, callback: Callable[[ChannelUnbanEvent], Awaitable[None]]) -> str: """A viewer is unbanned from the specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MODERATE` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelunban :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.unban', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelUnbanEvent) async def listen_channel_moderator_add(self, broadcaster_user_id: str, callback: Callable[[ChannelModeratorAddEvent], Awaitable[None]]) -> str: """Moderator privileges were added to a user on a specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.MODERATION_READ` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelmoderatoradd :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.moderator.add', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelModeratorAddEvent) async def listen_channel_moderator_remove(self, broadcaster_user_id: str, callback: Callable[[ChannelModeratorRemoveEvent], Awaitable[None]]) -> str: """Moderator privileges were removed from a user on a specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.MODERATION_READ` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelmoderatorremove :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.moderator.remove', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelModeratorRemoveEvent) async def listen_channel_points_custom_reward_add(self, broadcaster_user_id: str, callback: Callable[[ChannelPointsCustomRewardAddEvent], Awaitable[None]]) -> str: """A custom channel points reward has been created for the specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_REDEMPTIONS` or :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_REDEMPTIONS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelchannel_points_custom_rewardadd :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.channel_points_custom_reward.add', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelPointsCustomRewardAddEvent) async def listen_channel_points_custom_reward_update(self, broadcaster_user_id: str, callback: Callable[[ChannelPointsCustomRewardUpdateEvent], Awaitable[None]], reward_id: Optional[str] = None) -> str: """A custom channel points reward has been updated for the specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_REDEMPTIONS` or :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_REDEMPTIONS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelchannel_points_custom_rewardupdate :param broadcaster_user_id: the id of the user you want to listen to :param reward_id: the id of the reward you want to get updates from. |default| :code:`None` :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.channel_points_custom_reward.update', '1', remove_none_values({ 'broadcaster_user_id': broadcaster_user_id, 'reward_id': reward_id}), callback, ChannelPointsCustomRewardUpdateEvent) async def listen_channel_points_custom_reward_remove(self, broadcaster_user_id: str, callback: Callable[[ChannelPointsCustomRewardRemoveEvent], Awaitable[None]], reward_id: Optional[str] = None) -> str: """A custom channel points reward has been removed from the specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_REDEMPTIONS` or :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_REDEMPTIONS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelchannel_points_custom_rewardremove :param broadcaster_user_id: the id of the user you want to listen to :param reward_id: the id of the reward you want to get updates from. |default| :code:`None` :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.channel_points_custom_reward.remove', '1', remove_none_values({ 'broadcaster_user_id': broadcaster_user_id, 'reward_id': reward_id}), callback, ChannelPointsCustomRewardRemoveEvent) async def listen_channel_points_custom_reward_redemption_add(self, broadcaster_user_id: str, callback: Callable[[ChannelPointsCustomRewardRedemptionAddEvent], Awaitable[None]], reward_id: Optional[str] = None) -> str: """A viewer has redeemed a custom channel points reward on the specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_REDEMPTIONS` or :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_REDEMPTIONS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelchannel_points_custom_reward_redemptionadd :param broadcaster_user_id: the id of the user you want to listen to :param reward_id: the id of the reward you want to get updates from. |default| :code:`None` :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.channel_points_custom_reward_redemption.add', '1', remove_none_values({ 'broadcaster_user_id': broadcaster_user_id, 'reward_id': reward_id}), callback, ChannelPointsCustomRewardRedemptionAddEvent) async def listen_channel_points_custom_reward_redemption_update(self, broadcaster_user_id: str, callback: Callable[[ChannelPointsCustomRewardRedemptionUpdateEvent], Awaitable[None]], reward_id: Optional[str] = None) -> str: """A redemption of a channel points custom reward has been updated for the specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_REDEMPTIONS` or :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_REDEMPTIONS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelchannel_points_custom_reward_redemptionupdate :param broadcaster_user_id: the id of the user you want to listen to :param reward_id: the id of the reward you want to get updates from. |default| :code:`None` :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.channel_points_custom_reward_redemption.update', '1', remove_none_values({ 'broadcaster_user_id': broadcaster_user_id, 'reward_id': reward_id}), callback, ChannelPointsCustomRewardRedemptionUpdateEvent) async def listen_channel_poll_begin(self, broadcaster_user_id: str, callback: Callable[[ChannelPollBeginEvent], Awaitable[None]]) -> str: """A poll started on a specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_POLLS` or :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_POLLS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelpollbegin :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.poll.begin', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelPollBeginEvent) async def listen_channel_poll_progress(self, broadcaster_user_id: str, callback: Callable[[ChannelPollProgressEvent], Awaitable[None]]) -> str: """Users respond to a poll on a specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_POLLS` or :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_POLLS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelpollprogress :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.poll.progress', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelPollProgressEvent) async def listen_channel_poll_end(self, broadcaster_user_id: str, callback: Callable[[ChannelPollEndEvent], Awaitable[None]]) -> str: """A poll ended on a specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_POLLS` or :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_POLLS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelpollend :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.poll.end', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelPollEndEvent) async def listen_channel_prediction_begin(self, broadcaster_user_id: str, callback: Callable[[ChannelPredictionEvent], Awaitable[None]]) -> str: """A Prediction started on a specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_PREDICTIONS` or :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_PREDICTIONS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelpredictionbegin :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.prediction.begin', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelPredictionEvent) async def listen_channel_prediction_progress(self, broadcaster_user_id: str, callback: Callable[[ChannelPredictionEvent], Awaitable[None]]) -> str: """Users participated in a Prediction on a specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_PREDICTIONS` or :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_PREDICTIONS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelpredictionprogress :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.prediction.progress', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelPredictionEvent) async def listen_channel_prediction_lock(self, broadcaster_user_id: str, callback: Callable[[ChannelPredictionEvent], Awaitable[None]]) -> str: """A Prediction was locked on a specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_PREDICTIONS` or :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_PREDICTIONS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelpredictionlock :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.prediction.lock', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelPredictionEvent) async def listen_channel_prediction_end(self, broadcaster_user_id: str, callback: Callable[[ChannelPredictionEndEvent], Awaitable[None]]) -> str: """A Prediction ended on a specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_PREDICTIONS` or :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_PREDICTIONS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelpredictionend :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.prediction.end', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelPredictionEndEvent) async def listen_drop_entitlement_grant(self, organisation_id: str, callback: Callable[[DropEntitlementGrantEvent], Awaitable[None]], category_id: Optional[str] = None, campaign_id: Optional[str] = None) -> str: """An entitlement for a Drop is granted to a user. App access token required. The client ID associated with the access token must be owned by a user who is part of the specified organization. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#dropentitlementgrant :param organisation_id: The organization ID of the organization that owns the game on the developer portal. :param category_id: The category (or game) ID of the game for which entitlement notifications will be received. |default| :code:`None` :param campaign_id: The campaign ID for a specific campaign for which entitlement notifications will be received. |default| :code:`None` :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('drop.entitlement.grant', '1', remove_none_values({ 'organization_id': organisation_id, 'category_id': category_id, 'campaign_id': campaign_id }), callback, DropEntitlementGrantEvent, is_batching_enabled=True) async def listen_extension_bits_transaction_create(self, extension_client_id: str, callback: Callable[[ExtensionBitsTransactionCreateEvent], Awaitable[None]]) -> str: """A Bits transaction occurred for a specified Twitch Extension. The OAuth token client ID must match the Extension client ID. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#extensionbits_transactioncreate :param extension_client_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('extension.bits_transaction.create', '1', {'extension_client_id': extension_client_id}, callback, ExtensionBitsTransactionCreateEvent) async def listen_goal_begin(self, broadcaster_user_id: str, callback: Callable[[GoalEvent], Awaitable[None]]) -> str: """A goal begins on the specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_GOALS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelgoalbegin :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.goal.begin', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, GoalEvent) async def listen_goal_progress(self, broadcaster_user_id: str, callback: Callable[[GoalEvent], Awaitable[None]]) -> str: """A goal makes progress on the specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_GOALS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelgoalprogress :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.goal.progress', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, GoalEvent) async def listen_goal_end(self, broadcaster_user_id: str, callback: Callable[[GoalEvent], Awaitable[None]]) -> str: """A goal ends on the specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_GOALS` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelgoalend :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.goal.end', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, GoalEvent) async def listen_hype_train_begin(self, broadcaster_user_id: str, callback: Callable[[HypeTrainEvent], Awaitable[None]]) -> str: """A Hype Train begins on the specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_HYPE_TRAIN` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelhype_trainbegin :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.hype_train.begin', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, HypeTrainEvent) async def listen_hype_train_progress(self, broadcaster_user_id: str, callback: Callable[[HypeTrainEvent], Awaitable[None]]) -> str: """A Hype Train makes progress on the specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_HYPE_TRAIN` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelhype_trainprogress :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.hype_train.progress', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, HypeTrainEvent) async def listen_hype_train_end(self, broadcaster_user_id: str, callback: Callable[[HypeTrainEndEvent], Awaitable[None]]) -> str: """A Hype Train ends on the specified channel. User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_HYPE_TRAIN` is required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelhype_trainend :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('channel.hype_train.end', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, HypeTrainEndEvent) async def listen_stream_online(self, broadcaster_user_id: str, callback: Callable[[StreamOnlineEvent], Awaitable[None]]) -> str: """The specified broadcaster starts a stream. No authorization required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#streamonline :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('stream.online', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, StreamOnlineEvent) async def listen_stream_offline(self, broadcaster_user_id: str, callback: Callable[[StreamOfflineEvent], Awaitable[None]]) -> str: """The specified broadcaster stops a stream. No authorization required. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#streamoffline :param broadcaster_user_id: the id of the user you want to listen to :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('stream.offline', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, StreamOfflineEvent) async def listen_user_authorization_grant(self, client_id: str, callback: Callable[[UserAuthorizationGrantEvent], Awaitable[None]]) -> str: """A user’s authorization has been granted to your client id. Provided client_id must match the client id in the application access token. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#userauthorizationgrant :param client_id: Your application’s client id. :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('user.authorization.grant', '1', {'client_id': client_id}, callback, UserAuthorizationGrantEvent) async def listen_user_authorization_revoke(self, client_id: str, callback: Callable[[UserAuthorizationRevokeEvent], Awaitable[None]]) -> str: """A user’s authorization has been revoked for your client id. Provided client_id must match the client id in the application access token. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#userauthorizationrevoke :param client_id: Your application’s client id. :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('user.authorization.revoke', '1', {'client_id': client_id}, callback, UserAuthorizationRevokeEvent) async def listen_user_update(self, user_id: str, callback: Callable[[UserUpdateEvent], Awaitable[None]]) -> str: """A user has updated their account. No authorization required. If you have the :const:`~twitchAPI.type.AuthScope.USER_READ_EMAIL` scope, the notification will include email field. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#userupdate :param user_id: The user ID for the user you want update notifications for. :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ return await self._subscribe('user.update', '1', {'user_id': user_id}, callback, UserUpdateEvent) async def listen_channel_shield_mode_begin(self, broadcaster_user_id: str, moderator_user_id: str, callback: Callable[[ShieldModeEvent], Awaitable[None]]) -> str: """Sends a notification when the broadcaster activates Shield Mode. Requires the :const:`~twitchAPI.type.AuthScope.MODERATOR_READ_SHIELD_MODE` or :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_SHIELD_MODE` auth scope. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#channelshield_modebegin :param broadcaster_user_id: The ID of the broadcaster that you want to receive notifications about when they activate Shield Mode. :param moderator_user_id: The ID of the broadcaster or one of the broadcaster’s moderators. :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ param = { 'broadcaster_user_id': broadcaster_user_id, 'moderator_user_id': moderator_user_id } return await self._subscribe('channel.shield_mode.begin', '1', param, callback, ShieldModeEvent) async def listen_channel_shield_mode_end(self, broadcaster_user_id: str, moderator_user_id: str, callback: Callable[[ShieldModeEvent], Awaitable[None]]) -> str: """Sends a notification when the broadcaster deactivates Shield Mode. Requires the :const:`~twitchAPI.type.AuthScope.MODERATOR_READ_SHIELD_MODE` or :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_SHIELD_MODE` auth scope. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#channelshield_modeend :param broadcaster_user_id: The ID of the broadcaster that you want to receive notifications about when they deactivate Shield Mode. :param moderator_user_id: The ID of the broadcaster or one of the broadcaster’s moderators. :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ param = { 'broadcaster_user_id': broadcaster_user_id, 'moderator_user_id': moderator_user_id } return await self._subscribe('channel.shield_mode.end', '1', param, callback, ShieldModeEvent) async def listen_channel_charity_campaign_start(self, broadcaster_user_id: str, callback: Callable[[CharityCampaignStartEvent], Awaitable[None]]) -> str: """Sends a notification when the broadcaster starts a charity campaign. Requires the :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_CHARITY` auth scope. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#channelcharity_campaignstart :param broadcaster_user_id: The ID of the broadcaster that you want to receive notifications about when they start a charity campaign. :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ param = {'broadcaster_user_id': broadcaster_user_id} return await self._subscribe('channel.charity_campaign.start', '1', param, callback, CharityCampaignStartEvent) async def listen_channel_charity_campaign_progress(self, broadcaster_user_id: str, callback: Callable[[CharityCampaignProgressEvent], Awaitable[None]]) -> str: """Sends notifications when progress is made towards the campaign’s goal or when the broadcaster changes the fundraising goal. Requires the :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_CHARITY` auth scope. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#channelcharity_campaignprogress :param broadcaster_user_id: The ID of the broadcaster that you want to receive notifications about when their campaign makes progress or is updated. :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ param = {'broadcaster_user_id': broadcaster_user_id} return await self._subscribe('channel.charity_campaign.progress', '1', param, callback, CharityCampaignProgressEvent) async def listen_channel_charity_campaign_stop(self, broadcaster_user_id: str, callback: Callable[[CharityCampaignStopEvent], Awaitable[None]]) -> str: """Sends a notification when the broadcaster stops a charity campaign. Requires the :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_CHARITY` auth scope. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#channelcharity_campaignstop :param broadcaster_user_id: The ID of the broadcaster that you want to receive notifications about when they stop a charity campaign. :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ param = {'broadcaster_user_id': broadcaster_user_id} return await self._subscribe('channel.charity_campaign.stop', '1', param, callback, CharityCampaignStopEvent) async def listen_channel_charity_campaign_donate(self, broadcaster_user_id: str, callback: Callable[[CharityDonationEvent], Awaitable[None]]) -> str: """Sends a notification when a user donates to the broadcaster’s charity campaign. Requires the :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_CHARITY` auth scope. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#channelcharity_campaigndonate :param broadcaster_user_id: The ID of the broadcaster that you want to receive notifications about when users donate to their campaign. :param callback: function for callback :raises ~twitchAPI.type.EventSubSubscriptionConflict: if a conflict was found with this subscription (e.g. already subscribed to this exact topic) :raises ~twitchAPI.type.EventSubSubscriptionTimeout: if :const:`~twitchAPI.eventsub.webhook.EventSubWebhook.wait_for_subscription_confirm` is true and the subscription was not fully confirmed in time :raises ~twitchAPI.type.EventSubSubscriptionError: if the subscription failed (see error message for details) :raises ~twitchAPI.type.TwitchBackendException: if the subscription failed due to a twitch backend error """ param = {'broadcaster_user_id': broadcaster_user_id} return await self._subscribe('channel.charity_campaign.donate', '1', param, callback, CharityDonationEvent) async def listen_channel_shoutout_create(self, broadcaster_user_id: str, moderator_user_id: str, callback: Callable[[ChannelShoutoutCreateEvent], Awaitable[None]]) -> str: """Sends a notification when the specified broadcaster sends a Shoutout. Requires the :const:`~twitchAPI.type.AuthScope.MODERATOR_READ_SHOUTOUTS` or :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_SHOUTOUTS` auth scope. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#channelshoutoutcreate :param broadcaster_user_id: The ID of the broadcaster that you want to receive notifications about when they send a Shoutout. :param moderator_user_id: The ID of the broadcaster that gave the Shoutout or one of the broadcaster’s moderators. :param callback: function for callback """ param = { 'broadcaster_user_id': broadcaster_user_id, 'moderator_user_id': moderator_user_id } return await self._subscribe('channel.shoutout.create', '1', param, callback, ChannelShoutoutCreateEvent) async def listen_channel_shoutout_receive(self, broadcaster_user_id: str, moderator_user_id: str, callback: Callable[[ChannelShoutoutReceiveEvent], Awaitable[None]]) -> str: """Sends a notification when the specified broadcaster receives a Shoutout. Requires the :const:`~twitchAPI.type.AuthScope.MODERATOR_READ_SHOUTOUTS` or :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_SHOUTOUTS` auth scope. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#channelshoutoutreceive :param broadcaster_user_id: The ID of the broadcaster that you want to receive notifications about when they receive a Shoutout. :param moderator_user_id: The ID of the broadcaster that received the Shoutout or one of the broadcaster’s moderators. :param callback: function for callback """ param = { 'broadcaster_user_id': broadcaster_user_id, 'moderator_user_id': moderator_user_id } return await self._subscribe('channel.shoutout.receive', '1', param, callback, ChannelShoutoutReceiveEvent) async def listen_channel_chat_clear(self, broadcaster_user_id: str, user_id: str, callback: Callable[[ChannelChatClearEvent], Awaitable[None]]) -> str: """A moderator or bot has cleared all messages from the chat room. Requires :const:`~twitchAPI.type.AuthScope.USER_READ_CHAT` scope from chatting user. If app access token used, then additionally requires :const:`~twitchAPI.type.AuthScope.USER_BOT` scope from chatting user, and either :const:`~twitchAPI.type.AuthScope.CHANNEL_BOT` scope from broadcaster or moderator status. For more information see here: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#channelchatclear :param broadcaster_user_id: User ID of the channel to receive chat clear events for. :param user_id: The user ID to read chat as. :param callback: function for callback """ param = { 'broadcaster_user_id': broadcaster_user_id, 'user_id': user_id } return await self._subscribe('channel.chat.clear', '1', param, callback, ChannelChatClearEvent) async def listen_channel_chat_clear_user_messages(self, broadcaster_user_id: str, user_id: str, callback: Callable[[ChannelChatClearUserMessagesEvent], Awaitable[None]]) -> str: """A moderator or bot has cleared all messages from a specific user. Requires :const:`~twitchAPI.type.AuthScope.USER_READ_CHAT` scope from chatting user. If app access token used, then additionally requires :const:`~twitchAPI.type.AuthScope.USER_BOT` scope from chatting user, and either :const:`~twitchAPI.type.AuthScope.CHANNEL_BOT` scope from broadcaster or moderator status. :param broadcaster_user_id: User ID of the channel to receive chat clear user messages events for. :param user_id: The user ID to read chat as. :param callback: function for callback """ param = { 'broadcaster_user_id': broadcaster_user_id, 'user_id': user_id } return await self._subscribe('channel.chat.clear_user_messages', '1', param, callback, ChannelChatClearUserMessagesEvent) async def listen_channel_chat_message_delete(self, broadcaster_user_id: str, user_id: str, callback: Callable[[ChannelChatMessageDeleteEvent], Awaitable[None]]) -> str: """A moderator has removed a specific message. Requires :const:`~twitchAPI.type.AuthScope.USER_READ_CHAT` scope from chatting user. If app access token used, then additionally requires :const:`~twitchAPI.type.AuthScope.USER_BOT` scope from chatting user, and either :const:`~twitchAPI.type.AuthScope.CHANNEL_BOT` scope from broadcaster or moderator status. :param broadcaster_user_id: User ID of the channel to receive chat message delete events for. :param user_id: The user ID to read chat as. :param callback: function for callback """ param = { 'broadcaster_user_id': broadcaster_user_id, 'user_id': user_id } return await self._subscribe('channel.chat.message_delete', '1', param, callback, ChannelChatMessageDeleteEvent) async def listen_channel_chat_notification(self, broadcaster_user_id: str, user_id: str, callback: Callable[[ChannelChatNotificationEvent], Awaitable[None]]) -> str: """A notification for when an event that appears in chat has occurred. Requires :const:`~twitchAPI.type.AuthScope.USER_READ_CHAT` scope from chatting user. If app access token used, then additionally requires :const:`~twitchAPI.type.AuthScope.USER_BOT` scope from chatting user, and either :const:`~twitchAPI.type.AuthScope.CHANNEL_BOT` scope from broadcaster or moderator status. :param broadcaster_user_id: User ID of the channel to receive chat notification events for. :param user_id: The user ID to read chat as. :param callback: function for callback """ param = { 'broadcaster_user_id': broadcaster_user_id, 'user_id': user_id } return await self._subscribe('channel.chat.notification', '1', param, callback, ChannelChatNotificationEvent) async def listen_channel_ad_break_begin(self, broadcaster_user_id: str, callback: Callable[[ChannelAdBreakBeginEvent], Awaitable[None]]) -> str: """A midroll commercial break has started running. Requires the :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_ADS` scope. :param broadcaster_user_id: The ID of the broadcaster that you want to get Channel Ad Break begin notifications for. :param callback: function for callback """ return await self._subscribe('channel.ad_break.begin', '1', {'broadcaster_user_id': broadcaster_user_id}, callback, ChannelAdBreakBeginEvent) async def listen_channel_chat_message(self, broadcaster_user_id: str, user_id: str, callback: Callable[[ChannelChatMessageEvent], Awaitable[None]]) -> str: """Any user sends a message to a specific chat room. :param broadcaster_user_id: User ID of the channel to receive chat message events for. :param user_id: The user ID to read chat as. :param callback: function for callback """ param = { 'broadcaster_user_id': broadcaster_user_id, 'user_id': user_id } return await self._subscribe('channel.chat.message', '1', param, callback, ChannelChatMessageEvent) Teekeks-pyTwitchAPI-0d97664/twitchAPI/eventsub/webhook.py000066400000000000000000000424031463733066200232510ustar00rootroot00000000000000# Copyright (c) 2023. Lena "Teekeks" During """ EventSub Webhook ---------------- .. note:: EventSub Webhook is targeted at programs which have to subscribe to topics for multiple broadcasters.\n Should you only need to target a single broadcaster or are building a client side projekt, look at :doc:`/modules/twitchAPI.eventsub.websocket` EventSub lets you listen for events that happen on Twitch. The EventSub client runs in its own thread, calling the given callback function whenever an event happens. ************ Requirements ************ .. 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 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. 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 bridge between this program and your public URL on port 443 via reverse proxy.\n You can check on whether or not your webhook is publicly reachable by navigating to the URL set in `callback_url`. You should get a 200 response with the text :code:`pyTwitchAPI eventsub`. ******************* Listening to topics ******************* After you started your EventSub client, you can use the :code:`listen_` prefixed functions to listen to the topics you are interested in. Look at :ref:`eventsub-available-topics` to find the topics you are interested in. The function you hand in as callback will be called whenever that event happens with the event data as a parameter, the type of that parameter is also listed in the link above. ************ Code Example ************ .. code-block:: python from twitchAPI.twitch import Twitch from twitchAPI.helper import first from twitchAPI.eventsub.webhook import EventSubWebhook from twitchAPI.object.eventsub import ChannelFollowEvent from twitchAPI.oauth import UserAuthenticator from twitchAPI.type import AuthScope import asyncio TARGET_USERNAME = 'target_username_here' EVENTSUB_URL = 'https://url.to.your.webhook.com' APP_ID = 'your_app_id' APP_SECRET = 'your_app_secret' TARGET_SCOPES = [AuthScope.MODERATOR_READ_FOLLOWERS] async def on_follow(data: ChannelFollowEvent): # our event happend, lets do things with the data we got! print(f'{data.event.user_name} now follows {data.event.broadcaster_user_name}!') async def eventsub_webhook_example(): # create the api instance and get the ID of the target user twitch = await Twitch(APP_ID, APP_SECRET) user = await first(twitch.get_users(logins=TARGET_USERNAME)) # the user has to authenticate once using the bot with our intended scope. # since we do not need the resulting token after this authentication, we just discard the result we get from authenticate() # Please read up the UserAuthenticator documentation to get a full view of how this process works auth = UserAuthenticator(twitch, TARGET_SCOPES) await auth.authenticate() # basic setup, will run on port 8080 and a reverse proxy takes care of the https and certificate eventsub = EventSubWebhook(EVENTSUB_URL, 8080, twitch) # unsubscribe from all old events that might still be there # this will ensure we have a clean slate await eventsub.unsubscribe_all() # start the eventsub client eventsub.start() # subscribing to the desired eventsub hook for our user # the given function (in this example on_follow) will be called every time this event is triggered # the broadcaster is a moderator in their own channel by default so specifying both as the same works in this example await eventsub.listen_channel_follow_v2(user.id, user.id, on_follow) # eventsub will run in its own process # so lets just wait for user input before shutting it all down again try: input('press Enter to shut down...') finally: # stopping both eventsub as well as gracefully closing the connection to the API await eventsub.stop() await twitch.close() print('done') # lets run our example asyncio.run(eventsub_webhook_example())""" import asyncio import hashlib import hmac import threading from functools import partial from json import JSONDecodeError from random import choice from string import ascii_lowercase from ssl import SSLContext from time import sleep from typing import Optional, Union, Callable, Awaitable import datetime from collections import deque from aiohttp import web, ClientSession from twitchAPI.eventsub.base import EventSubBase from ..twitch import Twitch from ..helper import done_task_callback from ..type import TwitchBackendException, EventSubSubscriptionConflict, EventSubSubscriptionError, EventSubSubscriptionTimeout, \ TwitchAuthorizationException __all__ = ['EventSubWebhook'] class EventSubWebhook(EventSubBase): def __init__(self, callback_url: str, port: int, twitch: Twitch, ssl_context: Optional[SSLContext] = None, host_binding: str = '0.0.0.0', subscription_url: Optional[str] = None, callback_loop: Optional[asyncio.AbstractEventLoop] = None, revocation_handler: Optional[Callable[[dict], Awaitable[None]]] = None, message_deduplication_history_length: int = 50): """ :param callback_url: The full URL of the webhook. :param port: the port on which this webhook should run :param twitch: a app authenticated instance of :const:`~twitchAPI.twitch.Twitch` :param ssl_context: optional ssl context to be used |default| :code:`None` :param host_binding: the host to bind the internal server to |default| :code:`0.0.0.0` :param subscription_url: Alternative subscription URL, usefull for development with the twitch-cli :param callback_loop: The asyncio eventloop to be used for callbacks. \n Set this if you or a library you use cares about which asyncio event loop is running the callbacks. Defaults to the one used by EventSub Webhook. :param revocation_handler: Optional handler for when subscriptions get revoked. |default| :code:`None` :param message_deduplication_history_length: The amount of messages being considered for the duplicate message deduplication. |default| :code:`50` """ super().__init__(twitch) self.logger.name = 'twitchAPI.eventsub.webhook' self.callback_url: str = callback_url """The full URL of the webhook.""" if self.callback_url[-1] == '/': self.callback_url = self.callback_url[:-1] self.secret: str = ''.join(choice(ascii_lowercase) for _ in range(20)) """A random secret string. Set this for added security. |default| :code:`A random 20 character long string`""" self.wait_for_subscription_confirm: bool = True """Set this to false if you don't want to wait for a subscription confirm. |default| :code:`True`""" self.wait_for_subscription_confirm_timeout: int = 30 """Max time in seconds to wait for a subscription confirmation. Only used if ``wait_for_subscription_confirm`` is set to True. |default| :code:`30`""" self._port: int = port self.subscription_url: Optional[str] = subscription_url """Alternative subscription URL, usefull for development with the twitch-cli""" if self.subscription_url is not None and self.subscription_url[-1] != '/': self.subscription_url += '/' self._callback_loop = callback_loop self._host: str = host_binding self.__running = False self.revokation_handler: Optional[Callable[[dict], Awaitable[None]]] = revocation_handler """Optional handler for when subscriptions get revoked.""" self._startup_complete = False self.unsubscribe_on_stop: bool = True """Unsubscribe all currently active Webhooks on calling :const:`~twitchAPI.eventsub.EventSub.stop()` |default| :code:`True`""" self._closing = False self.__ssl_context: Optional[SSLContext] = ssl_context self.__active_webhooks = {} self.__hook_thread: Union['threading.Thread', None] = None self.__hook_loop: Union['asyncio.AbstractEventLoop', None] = None self.__hook_runner: Union['web.AppRunner', None] = None self._task_callback = partial(done_task_callback, self.logger) if not self.callback_url.startswith('https'): raise RuntimeError('HTTPS is required for authenticated webhook.\n' + 'Either use non authenticated webhook or use a HTTPS proxy!') self._msg_id_history: deque = deque(maxlen=message_deduplication_history_length) async def _unsubscribe_hook(self, topic_id: str) -> bool: return True def __build_runner(self): hook_app = web.Application() hook_app.add_routes([web.post('/callback', self.__handle_callback), web.get('/', self.__handle_default)]) return web.AppRunner(hook_app) def __run_hook(self, runner: 'web.AppRunner'): self.__hook_runner = runner self.__hook_loop = asyncio.new_event_loop() if self._callback_loop is None: self._callback_loop = self.__hook_loop asyncio.set_event_loop(self.__hook_loop) self.__hook_loop.run_until_complete(runner.setup()) site = web.TCPSite(runner, str(self._host), self._port, ssl_context=self.__ssl_context) self.__hook_loop.run_until_complete(site.start()) self.logger.info('started twitch API event sub on port ' + str(self._port)) self._startup_complete = True self.__hook_loop.run_until_complete(self._keep_loop_alive()) async def _keep_loop_alive(self): while not self._closing: await asyncio.sleep(0.1) def start(self): """Starts the EventSub client :rtype: None :raises RuntimeError: if EventSub is already running """ if self.__running: raise RuntimeError('already started') self.__hook_thread = threading.Thread(target=self.__run_hook, args=(self.__build_runner(),)) self.__running = True self._startup_complete = False self._closing = False self.__hook_thread.start() while not self._startup_complete: sleep(0.1) async def stop(self): """Stops the EventSub client This also unsubscribes from all known subscriptions if unsubscribe_on_stop is True :rtype: None :raises RuntimeError: if EventSub is not running """ if not self.__running: raise RuntimeError('EventSubWebhook is not running') self.logger.debug('shutting down eventsub') if self.__hook_runner is not None and self.unsubscribe_on_stop: await self.unsubscribe_all_known() # ensure all client sessions are closed await asyncio.sleep(0.25) self._closing = True # cleanly shut down the runner await self.__hook_runner.shutdown() await self.__hook_runner.cleanup() self.__hook_runner = None self.__running = False self.logger.debug('eventsub shut down') def _get_transport(self): return { 'method': 'webhook', 'callback': f'{self.callback_url}/callback', 'secret': self.secret } async def _build_request_header(self): token = await self._twitch.get_refreshed_app_token() if token is None: raise TwitchAuthorizationException('no Authorization set!') return { 'Client-ID': self._twitch.app_id, 'Content-Type': 'application/json', 'Authorization': f'Bearer {token}' } async def _subscribe(self, sub_type: str, sub_version: str, condition: dict, callback, event, is_batching_enabled: Optional[bool] = None) -> str: """"Subscribe to Twitch Topic""" if not asyncio.iscoroutinefunction(callback): raise ValueError('callback needs to be a async function which takes one parameter') self.logger.debug(f'subscribe to {sub_type} version {sub_version} with condition {condition}') data = { 'type': sub_type, 'version': sub_version, 'condition': condition, 'transport': self._get_transport() } if is_batching_enabled is not None: data['is_batching_enabled'] = is_batching_enabled async with ClientSession(timeout=self._twitch.session_timeout) as session: sub_base = self.subscription_url if self.subscription_url is not None else self._twitch.base_url r_data = await self._api_post_request(session, sub_base + 'eventsub/subscriptions', data=data) result = await r_data.json() error = result.get('error') if r_data.status == 500: raise TwitchBackendException(error) if error is not None: if error.lower() == 'conflict': raise EventSubSubscriptionConflict(result.get('message', '')) raise EventSubSubscriptionError(result.get('message')) sub_id = result['data'][0]['id'] self.logger.debug(f'subscription for {sub_type} version {sub_version} with condition {condition} has id {sub_id}') self._add_callback(sub_id, callback, event) if self.wait_for_subscription_confirm: timeout = datetime.datetime.utcnow() + datetime.timedelta( seconds=self.wait_for_subscription_confirm_timeout) while timeout >= datetime.datetime.utcnow(): if self._callbacks[sub_id]['active']: return sub_id await asyncio.sleep(0.01) self._callbacks.pop(sub_id, None) raise EventSubSubscriptionTimeout() return sub_id async def _verify_signature(self, request: 'web.Request') -> bool: expected = request.headers['Twitch-Eventsub-Message-Signature'] hmac_message = request.headers['Twitch-Eventsub-Message-Id'] + \ request.headers['Twitch-Eventsub-Message-Timestamp'] + await request.text() sig = 'sha256=' + hmac.new(bytes(self.secret, 'utf-8'), msg=bytes(hmac_message, 'utf-8'), digestmod=hashlib.sha256).hexdigest().lower() return sig == expected # noinspection PyUnusedLocal @staticmethod async def __handle_default(request: 'web.Request'): return web.Response(text="pyTwitchAPI EventSub") async def __handle_challenge(self, request: 'web.Request', data: dict): self.logger.debug(f'received challenge for subscription {data.get("subscription").get("id")}') if not await self._verify_signature(request): self.logger.warning(f'message signature is not matching! Discarding message') return web.Response(status=403) await self._activate_callback(data.get('subscription').get('id')) return web.Response(text=data.get('challenge')) async def _handle_revokation(self, data): sub_id: str = data.get('subscription', {}).get('id') self.logger.debug(f'got revocation of subscription {sub_id} for reason {data.get("subscription").get("status")}') if sub_id not in self._callbacks.keys(): self.logger.warning(f'unknown subscription {sub_id} got revoked. ignore') return self._callbacks.pop(sub_id) if self.revokation_handler is not None: t = self._callback_loop.create_task(self.revokation_handler(data)) t.add_done_callback(self._task_callback) async def __handle_callback(self, request: 'web.Request'): try: data: dict = await request.json() except JSONDecodeError: self.logger.error('got request with malformed body! Discarding message') return web.Response(status=400) if data.get('challenge') is not None: return await self.__handle_challenge(request, data) sub_id = data.get('subscription', {}).get('id') callback = self._callbacks.get(sub_id) if callback is None: self.logger.error(f'received event for unknown subscription with ID {sub_id}') else: if not await self._verify_signature(request): self.logger.warning(f'message signature is not matching! Discarding message') return web.Response(status=403) msg_type = request.headers['Twitch-Eventsub-Message-Type'] if msg_type.lower() == 'revocation': await self._handle_revokation(data) else: msg_id = request.headers.get('Twitch-Eventsub-Message-Id') if msg_id is not None and msg_id in self._msg_id_history: self.logger.warning(f'got message with duplicate id {msg_id}! Discarding message') else: self._msg_id_history.append(msg_id) dat = callback['event'](**data) t = self._callback_loop.create_task(callback['callback'](dat)) t.add_done_callback(self._task_callback) return web.Response(status=200) Teekeks-pyTwitchAPI-0d97664/twitchAPI/eventsub/websocket.py000066400000000000000000000452451463733066200236100ustar00rootroot00000000000000# Copyright (c) 2023. Lena "Teekeks" During """ EventSub Websocket ------------------ .. note:: EventSub Websocket is targeted at programs which have to subscribe to topics for just a single broadcaster.\n Should you need to target multiple broadcasters or are building a server side projekt, look at :doc:`/modules/twitchAPI.eventsub.webhook` EventSub lets you listen for events that happen on Twitch. The EventSub client runs in its own thread, calling the given callback function whenever an event happens. ******************* Listening to topics ******************* After you started your EventSub client, you can use the :code:`listen_` prefixed functions to listen to the topics you are interested in. Look at :ref:`eventsub-available-topics` to find the topics you are interested in. The function you hand in as callback will be called whenever that event happens with the event data as a parameter, the type of that parameter is also listed in the link above. ************ Code Example ************ .. code-block:: python from twitchAPI.helper import first from twitchAPI.twitch import Twitch from twitchAPI.oauth import UserAuthenticationStorageHelper from twitchAPI.object.eventsub import ChannelFollowEvent from twitchAPI.eventsub.websocket import EventSubWebsocket from twitchAPI.type import AuthScope import asyncio APP_ID = 'your_app_id' APP_SECRET = 'your_app_secret' TARGET_SCOPES = [AuthScope.MODERATOR_READ_FOLLOWERS] async def on_follow(data: ChannelFollowEvent): # our event happend, lets do things with the data we got! print(f'{data.event.user_name} now follows {data.event.broadcaster_user_name}!') async def run(): # create the api instance and get user auth either from storage or website twitch = await Twitch(APP_ID, APP_SECRET) helper = UserAuthenticationStorageHelper(twitch, TARGET_SCOPES) await helper.bind() # get the currently logged in user user = await first(twitch.get_users()) # create eventsub websocket instance and start the client. eventsub = EventSubWebsocket(twitch) eventsub.start() # subscribing to the desired eventsub hook for our user # the given function (in this example on_follow) will be called every time this event is triggered # the broadcaster is a moderator in their own channel by default so specifying both as the same works in this example # We have to subscribe to the first topic within 10 seconds of eventsub.start() to not be disconnected. await eventsub.listen_channel_follow_v2(user.id, user.id, on_follow) # eventsub will run in its own process # so lets just wait for user input before shutting it all down again try: input('press Enter to shut down...') except KeyboardInterrupt: pass finally: # stopping both eventsub as well as gracefully closing the connection to the API await eventsub.stop() await twitch.close() asyncio.run(run()) """ import asyncio import datetime import json import threading from asyncio import CancelledError from functools import partial from time import sleep from typing import Optional, List, Dict, Callable, Awaitable import aiohttp from aiohttp import ClientSession, WSMessage from .base import EventSubBase __all__ = ['EventSubWebsocket'] from twitchAPI.twitch import Twitch from ..helper import TWITCH_EVENT_SUB_WEBSOCKET_URL, done_task_callback from ..type import AuthType, UnauthorizedException, TwitchBackendException, EventSubSubscriptionConflict, EventSubSubscriptionError, \ TwitchAuthorizationException class Session: def __init__(self, data: dict): self.id: str = data.get('id') self.keepalive_timeout_seconds: int = data.get('keepalive_timeout_seconds') self.status: str = data.get('status') self.reconnect_url: str = data.get('reconnect_url') class EventSubWebsocket(EventSubBase): def __init__(self, twitch: Twitch, connection_url: Optional[str] = None, subscription_url: Optional[str] = None, callback_loop: Optional[asyncio.AbstractEventLoop] = None, revocation_handler: Optional[Callable[[dict], Awaitable[None]]] = None): """ :param twitch: The Twitch instance to be used :param connection_url: Alternative connection URL, usefull for development with the twitch-cli :param subscription_url: Alternative subscription URL, usefull for development with the twitch-cli :param callback_loop: The asyncio eventloop to be used for callbacks. \n Set this if you or a library you use cares about which asyncio event loop is running the callbacks. Defaults to the one used by EventSub Websocket. :param revocation_handler: Optional handler for when subscriptions get revoked. |default| :code:`None` """ super().__init__(twitch) self.logger.name = 'twitchAPI.eventsub.websocket' self.subscription_url: Optional[str] = subscription_url """The URL where subscriptions are being send to. Defaults to :const:`~twitchAPI.helper.TWITCH_API_BASE_URL`""" if self.subscription_url is not None and self.subscription_url[-1] != '/': self.subscription_url += '/' self.connection_url: str = connection_url if connection_url is not None else TWITCH_EVENT_SUB_WEBSOCKET_URL """The URL where the websocket connects to. Defaults to :const:`~twitchAPI.helper.TWITCH_EVENT_SUB_WEBSOCKET_URL`""" self.active_session: Optional[Session] = None """The currently used session""" self._running: bool = False self._socket_thread = None self._startup_complete: bool = False self._socket_loop = None self._ready: bool = False self._closing: bool = False self._connection = None self._session = None self._callback_loop = callback_loop self._is_reconnecting: bool = False self._active_subscriptions = {} self.revokation_handler: Optional[Callable[[dict], Awaitable[None]]] = revocation_handler """Optional handler for when subscriptions get revoked.""" self._task_callback = partial(done_task_callback, self.logger) self._reconnect_timeout: Optional[datetime.datetime] = None self.reconnect_delay_steps: List[int] = [0, 1, 2, 4, 8, 16, 32, 64, 128] """Time in seconds between reconnect attempts""" def start(self): """Starts the EventSub client :raises RuntimeError: If EventSub is already running :raises ~twitchAPI.type.UnauthorizedException: If Twitch instance is missing user authentication """ self.logger.debug('starting websocket EventSub...') if self._running: raise RuntimeError('EventSubWebsocket is already started!') if not self._twitch.has_required_auth(AuthType.USER, []): raise UnauthorizedException('Twitch needs user authentication') self._startup_complete = False self._ready = False self._closing = False self._socket_thread = threading.Thread(target=self._run_socket) self._running = True self._active_subscriptions = {} self._socket_thread.start() while not self._startup_complete: sleep(0.01) self.logger.debug('EventSubWebsocket started up!') async def stop(self): """Stops the EventSub client :raises RuntimeError: If EventSub is not running """ if not self._running: raise RuntimeError('EventSubWebsocket is not running') self.logger.debug('stopping websocket EventSub...') self._startup_complete = False self._running = False self._ready = False f = asyncio.run_coroutine_threadsafe(self._stop(), self._socket_loop) f.result() def _get_transport(self): return { 'method': 'websocket', 'session_id': self.active_session.id } async def _subscribe(self, sub_type: str, sub_version: str, condition: dict, callback, event, is_batching_enabled: Optional[bool] = None) -> str: if not asyncio.iscoroutinefunction(callback): raise ValueError('callback needs to be a async function which takes one parameter') self.logger.debug(f'subscribe to {sub_type} version {sub_version} with condition {condition}') data = { 'type': sub_type, 'version': sub_version, 'condition': condition, 'transport': self._get_transport() } if is_batching_enabled is not None: data['is_batching_enabled'] = is_batching_enabled async with ClientSession(timeout=self._twitch.session_timeout) as session: sub_base = self.subscription_url if self.subscription_url is not None else self._twitch.base_url r_data = await self._api_post_request(session, sub_base + 'eventsub/subscriptions', data=data) result = await r_data.json() error = result.get('error') if r_data.status == 500: raise TwitchBackendException(error) if error is not None: if error.lower() == 'conflict': raise EventSubSubscriptionConflict(result.get('message', '')) raise EventSubSubscriptionError(result.get('message')) sub_id = result['data'][0]['id'] self.logger.debug(f'subscription for {sub_type} version {sub_version} with condition {condition} has id {sub_id}') self._add_callback(sub_id, callback, event) self._callbacks[sub_id]['active'] = True self._active_subscriptions[sub_id] = { 'sub_type': sub_type, 'sub_version': sub_version, 'condition': condition, 'callback': callback, 'event': event } return sub_id async def _connect(self, is_startup: bool = False): _con_url = self.connection_url if self.active_session is None or self.active_session.reconnect_url is None else \ self.active_session.reconnect_url if is_startup: self.logger.debug(f'connecting to {_con_url}...') else: self._is_reconnecting = True self.logger.debug(f'reconnecting using {_con_url}...') self._reconnect_timeout = None if self._connection is not None and not self._connection.closed: await self._connection.close() while not self._connection.closed: await asyncio.sleep(0.1) retry = 0 need_retry = True if self._session is None: self._session = aiohttp.ClientSession(timeout=self._twitch.session_timeout) while need_retry and retry < len(self.reconnect_delay_steps): need_retry = False try: self._connection = await self._session.ws_connect(_con_url) except Exception: self.logger.warning(f'connection attempt failed, retry in {self.reconnect_delay_steps[retry]} seconds...') await asyncio.sleep(self.reconnect_delay_steps[retry]) retry += 1 need_retry = True if retry >= len(self.reconnect_delay_steps): raise TwitchBackendException(f'can\'t connect to EventSub websocket {_con_url}') def _run_socket(self): self._socket_loop = asyncio.new_event_loop() if self._callback_loop is None: self._callback_loop = self._socket_loop asyncio.set_event_loop(self._socket_loop) self._socket_loop.run_until_complete(self._connect(is_startup=True)) self._tasks = [ asyncio.ensure_future(self._task_receive(), loop=self._socket_loop), asyncio.ensure_future(self._task_reconnect_handler(), loop=self._socket_loop) ] self._socket_loop.run_until_complete(self._keep_loop_alive()) async def _stop(self): await self._connection.close() await self._session.close() await asyncio.sleep(0.25) self._connection = None self._session = None self._closing = True async def _keep_loop_alive(self): while not self._closing: await asyncio.sleep(0.1) async def _task_reconnect_handler(self): try: while not self._closing: await asyncio.sleep(0.1) if self._reconnect_timeout is None: continue if self._reconnect_timeout <= datetime.datetime.now(): self.logger.warning('keepalive missed, connection lost. reconnecting...') self._reconnect_timeout = None await self._connect(is_startup=False) except CancelledError: return async def _task_receive(self): handler: Dict[str, Callable] = { 'session_welcome': self._handle_welcome, 'session_keepalive': self._handle_keepalive, 'notification': self._handle_notification, 'session_reconnect': self._handle_reconnect, 'revocation': self._handle_revocation } try: while not self._closing: if self._connection.closed: await asyncio.sleep(0.01) continue message: WSMessage = await self._connection.receive() if message.type == aiohttp.WSMsgType.TEXT: data = json.loads(message.data) _type = data.get('metadata', {}).get('message_type') _handler = handler.get(_type) if _handler is not None: asyncio.ensure_future(_handler(data)) # debug else: self.logger.warning(f'got message for unknown message_type: {_type}, ignoring...') elif message.type == aiohttp.WSMsgType.CLOSE: msg_lookup = { 4000: "4000 - Internal server error", 4001: "4001 - Client sent inbound traffic", 4002: "4002 - Client failed ping-pong", 4003: "4003 - Connection unused, you have to create a subscription within 10 seconds", 4004: "4004 - Reconnect grace time expired", 4005: "4005 - Network timeout", 4006: "4006 - Network error", 4007: "4007 - Invalid reconnect" } self.logger.info(f'Websocket closing: {msg_lookup.get(message.data, f" {message.data} - Unknown")}') elif message.type == aiohttp.WSMsgType.CLOSED: self.logger.debug('websocket is closing') if self._running: if self._is_reconnecting: continue try: await self._connect(is_startup=False) except TwitchBackendException: self.logger.exception('Connection to EventSub websocket lost and unable to reestablish connection!') break else: break elif message.type == aiohttp.WSMsgType.ERROR: self.logger.warning('error in websocket: ' + str(self._connection.exception())) break except CancelledError: return async def _build_request_header(self): token = await self._twitch.get_refreshed_user_auth_token() if token is None: raise TwitchAuthorizationException('no Authorization set!') return { 'Client-ID': self._twitch.app_id, 'Content-Type': 'application/json', 'Authorization': f'Bearer {token}' } async def _unsubscribe_hook(self, topic_id: str) -> bool: self._active_subscriptions.pop(topic_id, None) return True async def _resubscribe(self): self.logger.debug('resubscribe to all active subscriptions of this websocket...') subs = self._active_subscriptions.copy() self._active_subscriptions = {} for sub in subs.values(): try: await self._subscribe(**sub) except: self.logger.exception('exception while resubscribing') self.logger.debug('done resubscribing!') def _reset_timeout(self): self._reconnect_timeout = datetime.datetime.now() + datetime.timedelta(seconds=self.active_session.keepalive_timeout_seconds*2) async def _handle_revocation(self, data: dict): _payload = data.get('payload', {}) sub_id: str = _payload.get('subscription', {}).get('id') self.logger.debug(f'got revocation of subscription {sub_id} for reason {_payload.get("subscription").get("status")}') if sub_id not in self._active_subscriptions.keys(): self.logger.warning(f'unknown subscription {sub_id} got revoked. ignore') return self._active_subscriptions.pop(sub_id) self._callbacks.pop(sub_id) if self.revokation_handler is not None: t = self._callback_loop.create_task(self.revokation_handler(_payload)) t.add_done_callback(self._task_callback) async def _handle_reconnect(self, data: dict): session = data.get('payload', {}).get('session', {}) self.active_session = Session(session) self.logger.debug(f'got request from websocket to reconnect, reconnect url: {self.active_session.reconnect_url}') await self._connect(False) async def _handle_welcome(self, data: dict): session = data.get('payload', {}).get('session', {}) _old_session = self.active_session.status if self.active_session is not None else None self.active_session = Session(session) self.logger.debug(f'new session id: {self.active_session.id}') self._reset_timeout() if self._is_reconnecting and _old_session != "reconnecting": await self._resubscribe() self._is_reconnecting = False self._startup_complete = True async def _handle_keepalive(self, data: dict): self.logger.debug('got session keep alive') self._reset_timeout() async def _handle_notification(self, data: dict): self._reset_timeout() _payload = data.get('payload', {}) sub_id = _payload.get('subscription', {}).get('id') callback = self._callbacks.get(sub_id) if callback is None: self.logger.error(f'received event for unknown subscription with ID {sub_id}') else: t = self._callback_loop.create_task(callback['callback'](callback['event'](**_payload))) t.add_done_callback(self._task_callback) Teekeks-pyTwitchAPI-0d97664/twitchAPI/helper.py000066400000000000000000000222601463733066200212360ustar00rootroot00000000000000# Copyright (c) 2020. Lena "Teekeks" During """ Helper functions ----------------""" import asyncio import datetime import logging import time import urllib.parse import uuid from logging import Logger from typing import AsyncGenerator, TypeVar from enum import Enum from .type import AuthScope from typing import Union, List, Type, Optional __all__ = ['first', 'limit', 'TWITCH_API_BASE_URL', 'TWITCH_AUTH_BASE_URL', 'TWITCH_PUB_SUB_URL', 'TWITCH_CHAT_URL', 'TWITCH_EVENT_SUB_WEBSOCKET_URL', 'build_url', 'get_uuid', 'build_scope', 'fields_to_enum', 'make_enum', 'enum_value_or_none', 'datetime_to_str', 'remove_none_values', 'ResultType', 'RateLimitBucket', 'RATE_LIMIT_SIZES', 'done_task_callback'] T = TypeVar('T') TWITCH_API_BASE_URL: str = "https://api.twitch.tv/helix/" """The base url to the Twitch API endpoints""" TWITCH_AUTH_BASE_URL: str = "https://id.twitch.tv/oauth2/" """The base url to the twitch authentication endpoints""" TWITCH_PUB_SUB_URL: str = "wss://pubsub-edge.twitch.tv" """The url to the Twitch PubSub websocket""" TWITCH_CHAT_URL: str = "wss://irc-ws.chat.twitch.tv:443" """The url to the Twitch Chat websocket""" TWITCH_EVENT_SUB_WEBSOCKET_URL: str = 'wss://eventsub.wss.twitch.tv/ws' """The url to the Twitch EventSub websocket""" class ResultType(Enum): RETURN_TYPE = 0 STATUS_CODE = 1 TEXT = 2 def build_url(url: str, params: dict, remove_none: bool = False, split_lists: bool = False, enum_value: bool = True) -> str: """Build a valid url string :param url: base URL :param params: dictionary of URL parameter :param remove_none: if set all params that have a None value get removed |default| :code:`False` :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` :param enum_value: if true, automatically get value string from Enum values |default| :code:`True` :return: URL """ def get_val(val): if not enum_value: return str(val) if isinstance(val, Enum): return str(val.value) return str(val) def add_param(res, k, v): if len(res) > 0: res += "&" res += str(k) if v is not None: res += "=" + urllib.parse.quote(get_val(v)) return res result = "" for key, value in params.items(): if value is None and remove_none: continue if split_lists and isinstance(value, list): for va in value: result = add_param(result, key, va) else: result = add_param(result, key, value) return url + (("?" + result) if len(result) > 0 else "") def get_uuid() -> uuid.UUID: """Returns a random UUID""" return uuid.uuid4() def build_scope(scopes: List[AuthScope]) -> str: """Builds a valid scope string from list :param scopes: list of :class:`~twitchAPI.type.AuthScope` :returns: the valid auth scope string """ return ' '.join([s.value for s in scopes]) def fields_to_enum(data: Union[dict, list], fields: List[str], _enum: Type[Enum], default: Optional[Enum]) -> Union[dict, list]: """Iterates a dict or list and tries to replace every dict entry with key in fields with the correct Enum value :param data: dict or list :param fields: list of keys to be replaced :param _enum: Type of Enum to be replaced :param default: The default value if _enum does not contain the field value """ _enum_vals = [e.value for e in _enum.__members__.values()] def make_dict_field_enum(_data: dict, _fields: List[str], _enum: Type[Enum], _default: Optional[Enum]) -> Union[dict, Enum, None]: fd = _data if isinstance(_data, str): if _data not in _enum_vals: return _default else: return _enum(_data) for key, value in _data.items(): if isinstance(value, str): if key in fields: if value not in _enum_vals: fd[key] = _default else: fd[key] = _enum(value) elif isinstance(value, dict): fd[key] = make_dict_field_enum(value, _fields, _enum, _default) elif isinstance(value, list): fd[key] = fields_to_enum(value, _fields, _enum, _default) return fd if isinstance(data, list): return [make_dict_field_enum(d, fields, _enum, default) for d in data] else: return make_dict_field_enum(data, fields, _enum, default) def make_enum(data: Union[str, int], _enum: Type[Enum], default: Enum) -> Enum: """Takes in a value and maps it to the given Enum. If the value is not valid it will take the default. :param data: the value to map from :param _enum: the Enum type to map to :param default: the default value""" _enum_vals = [e.value for e in _enum.__members__.values()] if data in _enum_vals: return _enum(data) else: return default def enum_value_or_none(enum: Optional[Enum]) -> Union[None, str, int]: """Returns the value of the given Enum member or None :param enum: the Enum member""" return enum.value if enum is not None else None def datetime_to_str(dt: Optional[datetime.datetime]) -> Optional[str]: """ISO-8601 formats the given datetime, returns None if datetime is None :param dt: the datetime to format""" return dt.astimezone().isoformat() if dt is not None else None def remove_none_values(d: dict) -> dict: """Removes items where the value is None from the dict. This returns a new dict and does not manipulate the one given. :param d: the dict from which the None values should be removed""" return {k: v for k, v in d.items() if v is not None} async def first(gen: AsyncGenerator[T, None]) -> Optional[T]: """Returns the first value of the given AsyncGenerator Example: .. code-block:: python user = await first(twitch.get_users()) :param gen: The generator from which you want the first value""" try: return await gen.__anext__() except StopAsyncIteration: return None async def limit(gen: AsyncGenerator[T, None], num: int) -> AsyncGenerator[T, None]: """Limits the number of entries from the given AsyncGenerator to up to num. This example will give you the currently 5 most watched streams: .. code-block:: python async for stream in limit(twitch.get_streams(), 5): print(stream.title) :param gen: The generator from which you want the first n values :param num: the number of entries you want :raises ValueError: if num is less than 1 """ if num < 1: raise ValueError('num has to be a int > 1') c = 0 async for y in gen: c += 1 if c > num: break yield y class RateLimitBucket: """Handler used for chat rate limiting""" def __init__(self, bucket_length: int, bucket_size: int, scope: str, logger: Optional[logging.Logger] = None): """ :param bucket_length: time in seconds the bucket is valid for :param bucket_size: the number of entries that can be put into the bucket :param scope: the scope of this bucket (used for logging) :param logger: the logger to be used. If None the default logger is used """ self.scope = scope self.bucket_length = float(bucket_length) self.bucket_size = bucket_size self.reset = None self.content = 0 self.logger = logger self.lock: asyncio.Lock = asyncio.Lock() def get_delta(self, num: int) -> Optional[float]: current = time.time() if self.reset is None: self.reset = current + self.bucket_length if current >= self.reset: self.reset = current + self.bucket_length self.content = num else: self.content += num if self.content >= self.bucket_size: return self.reset - current return None def left(self) -> int: """Returns the space left in the current bucket""" return self.bucket_size - self.content def _warn(self, msg): if self.logger is not None: self.logger.warning(msg) else: logging.warning(msg) async def put(self, num: int = 1): """Puts :code:`num` usees into the current bucket and waits if rate limit is hit :param num: the number of uses put into the current bucket""" async with self.lock: delta = self.get_delta(num) if delta is not None: self._warn(f'Bucket {self.scope} got rate limited. wating {delta:.2f}s...') await asyncio.sleep(delta + 0.05) RATE_LIMIT_SIZES = { 'user': 20, 'mod': 100 } def done_task_callback(logger: Logger, task: asyncio.Task): """helper function used as a asyncio task done callback""" e = task.exception() if e is not None: logger.exception("Error while running callback", exc_info=e) Teekeks-pyTwitchAPI-0d97664/twitchAPI/oauth.py000066400000000000000000000470551463733066200211100ustar00rootroot00000000000000# Copyright (c) 2020. Lena "Teekeks" During """ User OAuth Authenticator and helper functions ============================================= User Authenticator ------------------ :const:`~twitchAPI.oauth.UserAuthenticator` is an alternative to various online services that give you a user auth token. It provides non-server and server options. Requirements for non-server environment *************************************** Since this tool opens a browser tab for the Twitch authentication, you can only use this tool on enviroments that can open a browser window and render the ``__ website. For my authenticator you have to add the following URL as a "OAuth Redirect URL": :code:`http://localhost:17563` You can set that `here in your twitch dev dashboard `__. Requirements for server environment *********************************** 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()`. Create the UserAuthenticator with the URL of your webserver that will handle the redirect, and add it as a "OAuth Redirect URL" You can set that `here in your twitch dev dashboard `__. .. seealso:: This tutorial has a more detailed example how to use UserAuthenticator on a headless server: :doc:`/tutorial/user-auth-headless` Code example ************ .. code-block:: python from twitchAPI.twitch import Twitch from twitchAPI.oauth import UserAuthenticator from twitchAPI.type import AuthScope twitch = await Twitch('my_app_id', 'my_app_secret') target_scope = [AuthScope.BITS_READ] auth = UserAuthenticator(twitch, target_scope, force_verify=False) # this will open your default browser and prompt you with the twitch verification website token, refresh_token = await auth.authenticate() # add User authentication await twitch.set_user_authentication(token, target_scope, refresh_token) User Authentication Storage Helper ---------------------------------- :const:`~twitchAPI.oauth.UserAuthenticationStorageHelper` provides a simplified way to store & reuse user tokens. Code example ************ .. code-block:: python twitch = await Twitch(APP_ID, APP_SECRET) helper = UserAuthenticationStorageHelper(twitch, TARGET_SCOPES) await helper.bind()" .. seealso:: See :doc:`/tutorial/reuse-user-token` for more information. Class Documentation ------------------- """ import json import os.path from pathlib import PurePath import aiohttp from .twitch import Twitch from .helper import build_url, build_scope, get_uuid, TWITCH_AUTH_BASE_URL, fields_to_enum from .type import AuthScope, InvalidRefreshTokenException, UnauthorizedException, TwitchAPIException from typing import Optional, Callable, Awaitable, Tuple import webbrowser from aiohttp import web import asyncio from threading import Thread from concurrent.futures import CancelledError from logging import getLogger, Logger from typing import List, Union __all__ = ['refresh_access_token', 'validate_token', 'get_user_info', 'revoke_token', 'UserAuthenticator', 'UserAuthenticationStorageHelper'] async def refresh_access_token(refresh_token: str, app_id: str, app_secret: str, session: Optional[aiohttp.ClientSession] = None, auth_base_url: str = TWITCH_AUTH_BASE_URL): """Simple helper function for refreshing a user access token. :param str refresh_token: the current refresh_token :param str app_id: the id of your app :param str app_secret: the secret key of your app :param ~aiohttp.ClientSession session: optionally a active client session to be used for the web request to avoid having to open a new one :param auth_base_url: The URL to the Twitch API auth server |default| :const:`~twitchAPI.helper.TWITCH_AUTH_BASE_URL` :return: access_token, refresh_token :raises ~twitchAPI.type.InvalidRefreshTokenException: if refresh token is invalid :raises ~twitchAPI.type.UnauthorizedException: if both refresh and access token are invalid (eg if the user changes their password of the app gets disconnected) :rtype: (str, str) """ param = { 'refresh_token': refresh_token, 'client_id': app_id, 'grant_type': 'refresh_token', 'client_secret': app_secret } url = build_url(auth_base_url + 'token', {}) ses = session if session is not None else aiohttp.ClientSession() async with ses.post(url, data=param) as result: data = await result.json() if session is None: await ses.close() if data.get('status', 200) == 400: raise InvalidRefreshTokenException(data.get('message', '')) if data.get('status', 200) == 401: raise UnauthorizedException(data.get('message', '')) return data['access_token'], data['refresh_token'] async def validate_token(access_token: str, session: Optional[aiohttp.ClientSession] = None, auth_base_url: str = TWITCH_AUTH_BASE_URL) -> dict: """Helper function for validating a user or app access token. https://dev.twitch.tv/docs/authentication/validate-tokens :param access_token: either a user or app OAuth access token :param session: optionally a active client session to be used for the web request to avoid having to open a new one :param auth_base_url: The URL to the Twitch API auth server |default| :const:`~twitchAPI.helper.TWITCH_AUTH_BASE_URL` :return: response from the api """ header = {'Authorization': f'OAuth {access_token}'} url = build_url(auth_base_url + 'validate', {}) ses = session if session is not None else aiohttp.ClientSession() async with ses.get(url, headers=header) as result: data = await result.json() if session is None: await ses.close() return fields_to_enum(data, ['scopes'], AuthScope, None) async def get_user_info(access_token: str, session: Optional[aiohttp.ClientSession] = None, auth_base_url: str = TWITCH_AUTH_BASE_URL) -> dict: """Helper function to get claims information from an OAuth2 access token. https://dev.twitch.tv/docs/authentication/getting-tokens-oidc/#getting-claims-information-from-an-access-token :param access_token: a OAuth2 access token :param session: optionally a active client session to be used for the web request to avoid having to open a new one :param auth_base_url: The URL to the Twitch API auth server |default| :const:`~twitchAPI.helper.TWITCH_AUTH_BASE_URL` :return: response from the API """ header = {'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json'} url = build_url(auth_base_url + 'userinfo', {}) ses = session if session is not None else aiohttp.ClientSession() async with ses.get(url, headers=header) as result: data = await result.json() if session is None: await ses.close() return data async def revoke_token(client_id: str, access_token: str, session: Optional[aiohttp.ClientSession] = None, auth_base_url: str = TWITCH_AUTH_BASE_URL) -> bool: """Helper function for revoking a user or app OAuth access token. https://dev.twitch.tv/docs/authentication/revoke-tokens :param str client_id: client id belonging to the access token :param str access_token: user or app OAuth access token :param ~aiohttp.ClientSession session: optionally a active client session to be used for the web request to avoid having to open a new one :param auth_base_url: The URL to the Twitch API auth server |default| :const:`~twitchAPI.helper.TWITCH_AUTH_BASE_URL` :rtype: bool :return: :code:`True` if revoking succeeded, otherwise :code:`False` """ url = build_url(auth_base_url + 'revoke', { 'client_id': client_id, 'token': access_token }) ses = session if session is not None else aiohttp.ClientSession() async with ses.post(url) as result: ret = result.status == 200 if session is None: await ses.close() return ret class UserAuthenticator: """Simple to use client for the Twitch User authentication flow. """ def __init__(self, twitch: 'Twitch', scopes: List[AuthScope], force_verify: bool = False, url: str = 'http://localhost:17563', auth_base_url: str = TWITCH_AUTH_BASE_URL): """ :param twitch: A twitch instance :param scopes: List of the desired Auth scopes :param force_verify: If this is true, the user will always be prompted for authorization by twitch |default| :code:`False` :param url: The reachable URL that will be opened in the browser. |default| :code:`http://localhost:17563` :param auth_base_url: The URL to the Twitch API auth server |default| :const:`~twitchAPI.helper.TWITCH_AUTH_BASE_URL` """ self._twitch: 'Twitch' = twitch self._client_id: str = twitch.app_id self.scopes: List[AuthScope] = scopes self.force_verify: bool = force_verify self.logger: Logger = getLogger('twitchAPI.oauth') """The logger used for OAuth related log messages""" self.url = url self.auth_base_url: str = auth_base_url self.document: str = """ pyTwitchAPI OAuth

Thanks for Authenticating with pyTwitchAPI!

You may now close this page. """ """The document that will be rendered at the end of the flow""" self.port: int = 17563 """The port that will be used. |default| :code:`17653`""" self.host: str = '0.0.0.0' """the host the webserver will bind to. |default| :code:`0.0.0.0`""" self.state: str = str(get_uuid()) self._callback_func = None self._server_running: bool = False self._loop: Union[asyncio.AbstractEventLoop, None] = None self._runner: Union[web.AppRunner, None] = None self._thread: Union[Thread, None] = None self._user_token: Union[str, None] = None self._can_close: bool = False self._is_closed = False def _build_auth_url(self): params = { 'client_id': self._twitch.app_id, 'redirect_uri': self.url, 'response_type': 'code', 'scope': build_scope(self.scopes), 'force_verify': str(self.force_verify).lower(), 'state': self.state } return build_url(self.auth_base_url + 'authorize', params) def _build_runner(self): app = web.Application() app.add_routes([web.get('/', self._handle_callback)]) return web.AppRunner(app) async def _run_check(self): while not self._can_close: await asyncio.sleep(0.1) await self._runner.shutdown() await self._runner.cleanup() self.logger.info('shutting down oauth Webserver') self._is_closed = True def _run(self, runner: web.AppRunner): self._runner = runner self._loop = asyncio.new_event_loop() asyncio.set_event_loop(self._loop) self._loop.run_until_complete(runner.setup()) site = web.TCPSite(runner, self.host, self.port) self._loop.run_until_complete(site.start()) self._server_running = True self.logger.info('running oauth Webserver') try: self._loop.run_until_complete(self._run_check()) except (CancelledError, asyncio.CancelledError): pass def _start(self): self._thread = Thread(target=self._run, args=(self._build_runner(),)) self._thread.start() def stop(self): """Manually stop the flow :rtype: None """ self._can_close = True async def _handle_callback(self, request: web.Request): val = request.rel_url.query.get('state') self.logger.debug(f'got callback with state {val}') # invalid state! if val != self.state: return web.Response(status=401) self._user_token = request.rel_url.query.get('code') if self._user_token is None: # must provide code return web.Response(status=400) if self._callback_func is not None: self._callback_func(self._user_token) return web.Response(text=self.document, content_type='text/html') def return_auth_url(self): """Returns the URL that will authenticate the app, used for headless server environments.""" return self._build_auth_url() async def mock_authenticate(self, user_id: str) -> str: """Authenticate with a mocked auth flow via ``twitch-cli`` For more info see :doc:`/tutorial/mocking` :param user_id: the id of the user to generate a auth token for :return: the user auth token """ param = { 'client_id': self._client_id, 'client_secret': self._twitch.app_secret, 'code': self._user_token, 'user_id': user_id, 'scope': build_scope(self.scopes), 'grant_type': 'user_token' } url = build_url(self.auth_base_url + 'authorize', param) async with aiohttp.ClientSession(timeout=self._twitch.session_timeout) as session: async with session.post(url) as response: data: dict = await response.json() if data is None or data.get('access_token') is None: raise TwitchAPIException(f'Authentication failed:\n{str(data)}') return data['access_token'] async def authenticate(self, callback_func: Optional[Callable[[str, str], None]] = None, user_token: Optional[str] = None, browser_name: Optional[str] = None, browser_new: int = 2): """Start the user authentication flow\n If callback_func is not set, authenticate will wait till the authentication process finished and then return the access_token and the refresh_token If user_token is set, it will be used instead of launching the webserver and opening the browser :param callback_func: Function to call once the authentication finished. :param user_token: Code obtained from twitch to request the access and refresh token. :param browser_name: The browser that should be used, None means that the system default is used. See `the webbrowser documentation `__ for more info |default|:code:`None` :param browser_new: controls in which way the link will be opened in the browser. See `the webbrowser documentation `__ for more info |default|:code:`2` :return: None if callback_func is set, otherwise access_token and refresh_token :raises ~twitchAPI.type.TwitchAPIException: if authentication fails :rtype: None or (str, str) """ self._callback_func = callback_func self._can_close = False self._user_token = None self._is_closed = False if user_token is None: self._start() # wait for the server to start up while not self._server_running: await asyncio.sleep(0.01) # open in browser browser = webbrowser.get(browser_name) browser.open(self._build_auth_url(), new=browser_new) while self._user_token is None: await asyncio.sleep(0.01) # now we need to actually get the correct token else: self._user_token = user_token self._is_closed = True param = { 'client_id': self._client_id, 'client_secret': self._twitch.app_secret, 'code': self._user_token, 'grant_type': 'authorization_code', 'redirect_uri': self.url } url = build_url(self.auth_base_url + 'token', param) async with aiohttp.ClientSession(timeout=self._twitch.session_timeout) as session: async with session.post(url) as response: data: dict = await response.json() if callback_func is None: self.stop() while not self._is_closed: await asyncio.sleep(0.1) if data.get('access_token') is None: raise TwitchAPIException(f'Authentication failed:\n{str(data)}') return data['access_token'], data['refresh_token'] elif user_token is not None: self._callback_func(data['access_token'], data['refresh_token']) class UserAuthenticationStorageHelper: """Helper for automating the generation and storage of a user auth token.\n See :doc:`/tutorial/reuse-user-token` for more detailed examples and use cases. Basic example use: .. code-block:: python twitch = await Twitch(APP_ID, APP_SECRET) helper = UserAuthenticationStorageHelper(twitch, TARGET_SCOPES) await helper.bind()""" def __init__(self, twitch: 'Twitch', scopes: List[AuthScope], storage_path: Optional[PurePath] = None, auth_generator_func: Optional[Callable[['Twitch', List[AuthScope]], Awaitable[Tuple[str, str]]]] = None, auth_base_url: str = TWITCH_AUTH_BASE_URL): self.twitch = twitch self.logger: Logger = getLogger('twitchAPI.oauth.storage_helper') """The logger used for OAuth Storage Helper related log messages""" self._target_scopes = scopes self.storage_path = storage_path if storage_path is not None else PurePath('user_token.json') self.auth_generator = auth_generator_func if auth_generator_func is not None else self._default_auth_gen self.auth_base_url: str = auth_base_url async def _default_auth_gen(self, twitch: 'Twitch', scopes: List[AuthScope]) -> (str, str): auth = UserAuthenticator(twitch, scopes, force_verify=True, auth_base_url=self.auth_base_url) return await auth.authenticate() async def _update_stored_tokens(self, token: str, refresh_token: str): self.logger.info('user token got refreshed and stored') with open(self.storage_path, 'w') as _f: json.dump({'token': token, 'refresh': refresh_token}, _f) async def bind(self): """Bind the helper to the provided instance of twitch and sets the user authentication.""" self.twitch.user_auth_refresh_callback = self._update_stored_tokens needs_auth = True if os.path.exists(self.storage_path): try: with open(self.storage_path, 'r') as _f: creds = json.load(_f) await self.twitch.set_user_authentication(creds['token'], self._target_scopes, creds['refresh']) except: self.logger.info('stored token invalid, refreshing...') else: needs_auth = False if needs_auth: token, refresh_token = await self.auth_generator(self.twitch, self._target_scopes) with open(self.storage_path, 'w') as _f: json.dump({'token': token, 'refresh': refresh_token}, _f) await self.twitch.set_user_authentication(token, self._target_scopes, refresh_token) Teekeks-pyTwitchAPI-0d97664/twitchAPI/object/000077500000000000000000000000001463733066200206515ustar00rootroot00000000000000Teekeks-pyTwitchAPI-0d97664/twitchAPI/object/__init__.py000066400000000000000000000005531463733066200227650ustar00rootroot00000000000000# Copyright (c) 2023. Lena "Teekeks" During """ Objects used by this Library ---------------------------- .. toctree:: :hidden: :maxdepth: 1 twitchAPI.object.base twitchAPI.object.api twitchAPI.object.eventsub .. autosummary:: twitchAPI.object.base twitchAPI.object.api twitchAPI.object.eventsub """ __all__ = [] Teekeks-pyTwitchAPI-0d97664/twitchAPI/object/api.py000066400000000000000000000442231463733066200220010ustar00rootroot00000000000000# Copyright (c) 2022. Lena "Teekeks" During """ Objects used by the Twitch API ------------------------------ """ from datetime import datetime from typing import Optional, List, Dict from twitchAPI.object.base import TwitchObject, IterTwitchObject, AsyncIterTwitchObject from twitchAPI.type import StatusCode, VideoType, HypeTrainContributionMethod, DropsEntitlementFulfillmentStatus, CustomRewardRedemptionStatus, \ PollStatus, PredictionStatus __all__ = ['TwitchUser', 'TwitchUserFollow', 'TwitchUserFollowResult', 'DateRange', 'ExtensionAnalytic', 'GameAnalytics', 'CreatorGoal', 'BitsLeaderboardEntry', 'BitsLeaderboard', 'ProductCost', 'ProductData', 'ExtensionTransaction', 'ChatSettings', 'CreatedClip', 'Clip', 'CodeStatus', 'Game', 'AutoModStatus', 'BannedUser', 'BanUserResponse', 'BlockedTerm', 'Moderator', 'CreateStreamMarkerResponse', 'Stream', 'StreamMarker', 'StreamMarkers', 'GetStreamMarkerResponse', 'BroadcasterSubscription', 'BroadcasterSubscriptions', 'UserSubscription', 'StreamTag', 'TeamUser', 'ChannelTeam', 'UserExtension', 'ActiveUserExtension', 'UserActiveExtensions', 'VideoMutedSegments', 'Video', 'ChannelInformation', 'SearchChannelResult', 'SearchCategoryResult', 'StartCommercialResult', 'Cheermote', 'GetCheermotesResponse', 'HypeTrainContribution', 'HypeTrainEventData', 'HypeTrainEvent', 'DropsEntitlement', 'MaxPerStreamSetting', 'MaxPerUserPerStreamSetting', 'GlobalCooldownSetting', 'CustomReward', 'PartialCustomReward', 'CustomRewardRedemption', 'ChannelEditor', 'BlockListEntry', 'PollChoice', 'Poll', 'Predictor', 'PredictionOutcome', 'Prediction', 'RaidStartResult', 'ChatBadgeVersion', 'ChatBadge', 'Emote', 'GetEmotesResponse', 'EventSubSubscription', 'GetEventSubSubscriptionResult', 'StreamCategory', 'ChannelStreamScheduleSegment', 'StreamVacation', 'ChannelStreamSchedule', 'ChannelVIP', 'UserChatColor', 'Chatter', 'GetChattersResponse', 'ShieldModeStatus', 'CharityAmount', 'CharityCampaign', 'CharityCampaignDonation', 'AutoModSettings', 'ChannelFollower', 'ChannelFollowersResult', 'FollowedChannel', 'FollowedChannelsResult', 'ContentClassificationLabel', 'AdSchedule', 'AdSnoozeResponse', 'SendMessageResponse', 'ChannelModerator'] class TwitchUser(TwitchObject): id: str login: str display_name: str type: str broadcaster_type: str description: str profile_image_url: str offline_image_url: str view_count: int email: str = None created_at: datetime class TwitchUserFollow(TwitchObject): from_id: str from_login: str from_name: str to_id: str to_login: str to_name: str followed_at: datetime class TwitchUserFollowResult(AsyncIterTwitchObject[TwitchUserFollow]): total: int data: List[TwitchUserFollow] class ChannelFollower(TwitchObject): followed_at: datetime user_id: str user_name: str user_login: str class ChannelFollowersResult(AsyncIterTwitchObject[ChannelFollower]): total: int data: List[ChannelFollower] class FollowedChannel(TwitchObject): broadcaster_id: str broadcaster_login: str broadcaster_name: str followed_at: datetime class FollowedChannelsResult(AsyncIterTwitchObject[FollowedChannel]): total: int data: List[FollowedChannel] class DateRange(TwitchObject): ended_at: datetime started_at: datetime class ExtensionAnalytic(TwitchObject): extension_id: str URL: str type: str date_range: DateRange class GameAnalytics(TwitchObject): game_id: str URL: str type: str date_range: DateRange class CreatorGoal(TwitchObject): id: str broadcaster_id: str broadcaster_name: str broadcaster_login: str type: str description: str current_amount: int target_amount: int created_at: datetime class BitsLeaderboardEntry(TwitchObject): user_id: str user_login: str user_name: str rank: int score: int class BitsLeaderboard(IterTwitchObject): data: List[BitsLeaderboardEntry] date_range: DateRange total: int class ProductCost(TwitchObject): amount: int type: str class ProductData(TwitchObject): domain: str sku: str cost: ProductCost class ExtensionTransaction(TwitchObject): id: str timestamp: datetime broadcaster_id: str broadcaster_login: str broadcaster_name: str user_id: str user_login: str user_name: str product_type: str product_data: ProductData inDevelopment: bool displayName: str expiration: str broadcast: str class ChatSettings(TwitchObject): broadcaster_id: str moderator_id: str emote_mode: bool slow_mode: bool slow_mode_wait_time: int follower_mode: bool follower_mode_duration: int subscriber_mode: bool unique_chat_mode: bool non_moderator_chat_delay: bool non_moderator_chat_delay_duration: int class CreatedClip(TwitchObject): id: str edit_url: str class Clip(TwitchObject): id: str url: str embed_url: str broadcaster_id: str broadcaster_name: str creator_id: str creator_name: str video_id: str game_id: str language: str title: str view_count: int created_at: datetime thumbnail_url: str duration: float vod_offset: int is_featured: bool class CodeStatus(TwitchObject): code: str status: StatusCode class Game(TwitchObject): box_art_url: str id: str name: str igdb_id: str class AutoModStatus(TwitchObject): msg_id: str is_permitted: bool class BannedUser(TwitchObject): user_id: str user_login: str user_name: str expires_at: datetime created_at: datetime reason: str moderator_id: str moderator_login: str moderator_name: str class BanUserResponse(TwitchObject): broadcaster_id: str moderator_id: str user_id: str created_at: datetime end_time: datetime class BlockedTerm(TwitchObject): broadcaster_id: str moderator_id: str id: str text: str created_at: datetime updated_at: datetime expires_at: datetime class Moderator(TwitchObject): user_id: str user_login: str user_name: str class CreateStreamMarkerResponse(TwitchObject): id: str created_at: datetime description: str position_seconds: int class Stream(TwitchObject): id: str user_id: str user_login: str user_name: str game_id: str game_name: str type: str title: str viewer_count: int started_at: datetime language: str thumbnail_url: str tag_ids: List[str] is_mature: bool tags: List[str] class StreamMarker(TwitchObject): id: str created_at: datetime description: str position_seconds: int URL: str class StreamMarkers(TwitchObject): video_id: str markers: List[StreamMarker] class GetStreamMarkerResponse(TwitchObject): user_id: str user_name: str user_login: str videos: List[StreamMarkers] class BroadcasterSubscription(TwitchObject): broadcaster_id: str broadcaster_login: str broadcaster_name: str gifter_id: str gifter_login: str gifter_name: str is_gift: bool tier: str plan_name: str user_id: str user_name: str user_login: str class BroadcasterSubscriptions(AsyncIterTwitchObject[BroadcasterSubscription]): total: int points: int data: List[BroadcasterSubscription] class UserSubscription(TwitchObject): broadcaster_id: str broadcaster_name: str broadcaster_login: str is_gift: bool tier: str class StreamTag(TwitchObject): tag_id: str is_auto: bool localization_names: Dict[str, str] localization_descriptions: Dict[str, str] class TeamUser(TwitchObject): user_id: str user_name: str user_login: str class ChannelTeam(TwitchObject): broadcaster_id: str broadcaster_name: str broadcaster_login: str background_image_url: str banner: str users: Optional[List[TeamUser]] created_at: datetime updated_at: datetime info: str thumbnail_url: str team_name: str team_display_name: str id: str class UserExtension(TwitchObject): id: str version: str can_activate: bool type: List[str] name: str class ActiveUserExtension(UserExtension): x: int y: int active: bool class UserActiveExtensions(TwitchObject): panel: Dict[str, ActiveUserExtension] overlay: Dict[str, ActiveUserExtension] component: Dict[str, ActiveUserExtension] class VideoMutedSegments(TwitchObject): duration: int offset: int class Video(TwitchObject): id: str stream_id: str user_id: str user_login: str user_name: str title: str description: str created_at: datetime published_at: datetime url: str thumbnail_url: str viewable: str view_count: int language: str type: VideoType duration: str muted_segments: List[VideoMutedSegments] class ChannelInformation(TwitchObject): broadcaster_id: str broadcaster_login: str broadcaster_name: str game_name: str game_id: str broadcaster_language: str title: str delay: int tags: List[str] content_classification_labels: List[str] is_branded_content: bool class SearchChannelResult(ChannelInformation): is_live: bool tags_ids: List[str] started_at: datetime class SearchCategoryResult(TwitchObject): id: str name: str box_art_url: str class StartCommercialResult(TwitchObject): length: int message: str retry_after: int class Cheermote(TwitchObject): min_bits: int id: str color: str images: Dict[str, Dict[str, Dict[str, str]]] can_cheer: bool show_in_bits_card: bool class GetCheermotesResponse(TwitchObject): prefix: str tiers: List[Cheermote] type: str order: int last_updated: datetime is_charitable: bool class HypeTrainContribution(TwitchObject): total: int type: HypeTrainContributionMethod user: str class HypeTrainEventData(TwitchObject): broadcaster_id: str cooldown_end_time: datetime expires_at: datetime goal: int id: str last_contribution: HypeTrainContribution level: int started_at: datetime top_contributions: List[HypeTrainContribution] total: int class HypeTrainEvent(TwitchObject): id: str event_type: str event_timestamp: datetime version: str event_data: HypeTrainEventData class DropsEntitlement(TwitchObject): id: str benefit_id: str timestamp: datetime user_id: str game_id: str fulfillment_status: DropsEntitlementFulfillmentStatus updated_at: datetime class MaxPerStreamSetting(TwitchObject): is_enabled: bool max_per_stream: int class MaxPerUserPerStreamSetting(TwitchObject): is_enabled: bool max_per_user_per_stream: int class GlobalCooldownSetting(TwitchObject): is_enabled: bool global_cooldown_seconds: int class CustomReward(TwitchObject): broadcaster_name: str broadcaster_login: str broadcaster_id: str id: str image: str background_color: str is_enabled: bool cost: int title: str prompt: str is_user_input_required: bool max_per_stream_setting: MaxPerStreamSetting max_per_user_per_stream_setting: MaxPerUserPerStreamSetting global_cooldown_setting: GlobalCooldownSetting is_paused: bool is_in_stock: bool default_image: Dict[str, str] should_redemptions_skip_request_queue: bool redemptions_redeemed_current_stream: int cooldown_expires_at: datetime class PartialCustomReward(TwitchObject): id: str title: str prompt: str cost: int class CustomRewardRedemption(TwitchObject): broadcaster_name: str broadcaster_login: str broadcaster_id: str id: str user_id: str user_name: str user_input: str status: CustomRewardRedemptionStatus redeemed_at: datetime reward: PartialCustomReward class ChannelEditor(TwitchObject): user_id: str user_name: str created_at: datetime class BlockListEntry(TwitchObject): user_id: str user_login: str user_name: str class PollChoice(TwitchObject): id: str title: str votes: int channel_point_votes: int class Poll(TwitchObject): id: str broadcaster_name: str broadcaster_id: str broadcaster_login: str title: str choices: List[PollChoice] channel_point_voting_enabled: bool channel_points_per_vote: int status: PollStatus duration: int started_at: datetime class Predictor(TwitchObject): user_id: str user_name: str user_login: str channel_points_used: int channel_points_won: int class PredictionOutcome(TwitchObject): id: str title: str users: int channel_points: int top_predictors: Optional[List[Predictor]] color: str class Prediction(TwitchObject): id: str broadcaster_id: str broadcaster_name: str broadcaster_login: str title: str winning_outcome_id: Optional[str] outcomes: List[PredictionOutcome] prediction_window: int status: PredictionStatus created_at: datetime ended_at: Optional[datetime] locked_at: Optional[datetime] class RaidStartResult(TwitchObject): created_at: datetime is_mature: bool class ChatBadgeVersion(TwitchObject): id: str image_url_1x: str image_url_2x: str image_url_4x: str title: str description: str click_action: Optional[str] click_url: Optional[str] class ChatBadge(TwitchObject): set_id: str versions: List[ChatBadgeVersion] class Emote(TwitchObject): id: str name: str images: Dict[str, str] tier: str emote_type: str emote_set_id: str format: List[str] scale: List[str] theme_mode: List[str] class GetEmotesResponse(IterTwitchObject): data: List[Emote] template: str class EventSubSubscription(TwitchObject): id: str status: str type: str version: str condition: Dict[str, str] created_at: datetime transport: Dict[str, str] cost: int class GetEventSubSubscriptionResult(AsyncIterTwitchObject[EventSubSubscription]): total: int total_cost: int max_total_cost: int data: List[EventSubSubscription] class StreamCategory(TwitchObject): id: str name: str class ChannelStreamScheduleSegment(TwitchObject): id: str start_time: datetime end_time: datetime title: str canceled_until: Optional[datetime] category: StreamCategory is_recurring: bool class StreamVacation(TwitchObject): start_time: datetime end_time: datetime class ChannelStreamSchedule(AsyncIterTwitchObject[ChannelStreamScheduleSegment]): segments: List[ChannelStreamScheduleSegment] broadcaster_id: str broadcaster_name: str broadcaster_login: str vacation: Optional[StreamVacation] class ChannelVIP(TwitchObject): user_id: str user_name: str user_login: str class UserChatColor(TwitchObject): user_id: str user_name: str user_login: str color: str class Chatter(TwitchObject): user_id: str user_login: str user_name: str class GetChattersResponse(AsyncIterTwitchObject[Chatter]): data: List[Chatter] total: int class ShieldModeStatus(TwitchObject): is_active: bool moderator_id: str moderator_login: str moderator_name: str last_activated_at: Optional[datetime] class CharityAmount(TwitchObject): value: int decimal_places: int currency: str class CharityCampaign(TwitchObject): id: str broadcaster_id: str broadcaster_login: str broadcaster_name: str charity_name: str charity_description: str charity_logo: str charity_website: str current_amount: CharityAmount target_amount: CharityAmount class CharityCampaignDonation(TwitchObject): id: str campaign_id: str user_id: str user_name: str user_login: str amount: CharityAmount class AutoModSettings(TwitchObject): broadcaster_id: str moderator_id: str overall_level: Optional[int] disability: int aggression: int sexuality_sex_or_gender: int misogyny: int bullying: int swearing: int race_ethnicity_or_religion: int sex_based_terms: int class ContentClassificationLabel(TwitchObject): id: str description: str name: str class AdSchedule(TwitchObject): snooze_count: int """The number of snoozes available for the broadcaster.""" snooze_refresh_at: Optional[datetime] """The UTC timestamp when the broadcaster will gain an additional snooze.""" next_ad_at: Optional[datetime] """The UTC timestamp of the broadcaster’s next scheduled ad. Empty if the channel has no ad scheduled or is not live.""" duration: int """The length in seconds of the scheduled upcoming ad break.""" last_ad_at: Optional[datetime] """The UTC timestamp of the broadcaster’s last ad-break. Empty if the channel has not run an ad or is not live.""" preroll_free_time: int """The amount of pre-roll free time remaining for the channel in seconds. Returns 0 if they are currently not pre-roll free.""" class AdSnoozeResponse(TwitchObject): snooze_count: int """The number of snoozes available for the broadcaster.""" snooze_refresh_at: Optional[datetime] """The UTC timestamp when the broadcaster will gain an additional snooze""" next_ad_at: Optional[datetime] """The UTC timestamp of the broadcaster’s next scheduled ad""" class SendMessageDropReason(TwitchObject): code: str """Code for why the message was dropped.""" message: str """Message for why the message was dropped.""" class SendMessageResponse(TwitchObject): message_id: str """The message id for the message that was sent.""" is_sent: bool """If the message passed all checks and was sent.""" drop_reason: Optional[SendMessageDropReason] """The reason the message was dropped, if any.""" class ChannelModerator(TwitchObject): broadcaster_id: str """An ID that uniquely identifies the channel this user can moderate.""" broadcaster_login: str """The channel’s login name.""" broadcaster_name: str """The channels’ display name.""" Teekeks-pyTwitchAPI-0d97664/twitchAPI/object/base.py000066400000000000000000000170411463733066200221400ustar00rootroot00000000000000# Copyright (c) 2023. Lena "Teekeks" During """ Base Objects used by the Library -------------------------------- """ from datetime import datetime from enum import Enum from typing import TypeVar, Union, Generic, Optional from aiohttp import ClientSession from dateutil import parser as du_parser from twitchAPI.helper import build_url T = TypeVar('T') __all__ = ['TwitchObject', 'IterTwitchObject', 'AsyncIterTwitchObject'] class TwitchObject: """ A lot of API calls return a child of this in some way (either directly or via generator). You can always use the :const:`~twitchAPI.object.TwitchObject.to_dict()` method to turn that object to a dictionary. Example: .. code-block:: python blocked_term = await twitch.add_blocked_term('broadcaster_id', 'moderator_id', 'bad_word') print(blocked_term.id)""" @staticmethod def _val_by_instance(instance, val): if val is None: return None origin = instance.__origin__ if hasattr(instance, '__origin__') else None if instance == datetime: if isinstance(val, int): # asume unix timestamp return None if val == 0 else datetime.fromtimestamp(val) # asume ISO8601 string return du_parser.isoparse(val) if len(val) > 0 else None elif origin == list: c = instance.__args__[0] return [TwitchObject._val_by_instance(c, x) for x in val] elif origin == dict: c1 = instance.__args__[0] c2 = instance.__args__[1] return {TwitchObject._val_by_instance(c1, x1): TwitchObject._val_by_instance(c2, x2) for x1, x2 in val.items()} elif origin == Union: # TODO: only works for optional pattern, fix to try out all possible patterns? c1 = instance.__args__[0] return TwitchObject._val_by_instance(c1, val) elif issubclass(instance, TwitchObject): return instance(**val) else: return instance(val) @staticmethod def _dict_val_by_instance(instance, val, include_none_values): if val is None: return None if instance is None: return val origin = instance.__origin__ if hasattr(instance, '__origin__') else None if instance == datetime: return val.isoformat() if val is not None else None elif origin == list: c = instance.__args__[0] return [TwitchObject._dict_val_by_instance(c, x, include_none_values) for x in val] elif origin == dict: c1 = instance.__args__[0] c2 = instance.__args__[1] return {TwitchObject._dict_val_by_instance(c1, x1, include_none_values): TwitchObject._dict_val_by_instance(c2, x2, include_none_values) for x1, x2 in val.items()} elif origin == Union: # TODO: only works for optional pattern, fix to try out all possible patterns? c1 = instance.__args__[0] return TwitchObject._dict_val_by_instance(c1, val, include_none_values) elif issubclass(instance, TwitchObject): return val.to_dict(include_none_values) elif isinstance(val, Enum): return val.value return instance(val) @classmethod def _get_annotations(cls): d = {} for c in cls.mro(): try: d.update(**c.__annotations__) except AttributeError: pass return d def to_dict(self, include_none_values: bool = False) -> dict: """build dict based on annotation types :param include_none_values: if fields that have None values should be included in the dictionary """ d = {} annotations = self._get_annotations() for name, val in self.__dict__.items(): val = None cls = annotations.get(name) try: val = getattr(self, name) except AttributeError: pass if val is None and not include_none_values: continue if name[0] == '_': continue d[name] = TwitchObject._dict_val_by_instance(cls, val, include_none_values) return d def __init__(self, **kwargs): merged_annotations = self._get_annotations() for name, cls in merged_annotations.items(): if name not in kwargs.keys(): continue self.__setattr__(name, TwitchObject._val_by_instance(cls, kwargs.get(name))) class IterTwitchObject(TwitchObject): """Special type of :const:`~twitchAPI.object.TwitchObject`. 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 outside of that List. Example: .. code-block:: python lb = await twitch.get_bits_leaderboard() print(lb.total) for e in lb: print(f'#{e.rank:02d} - {e.user_name}: {e.score}')""" def __iter__(self): if not hasattr(self, 'data') or not isinstance(self.__getattribute__('data'), list): raise ValueError('Object is missing data attribute of type list') for i in self.__getattribute__('data'): yield i class AsyncIterTwitchObject(TwitchObject, Generic[T]): """A few API calls will have useful data outside the list the pagination iterates over. For those cases, this object exist. Example: .. code-block:: python schedule = await twitch.get_channel_stream_schedule('user_id') print(schedule.broadcaster_name) async for segment in schedule: print(segment.title)""" def __init__(self, _data, **kwargs): super(AsyncIterTwitchObject, self).__init__(**kwargs) self.__idx = 0 self._data = _data def __aiter__(self): return self def current_cursor(self) -> Optional[str]: """Provides the currently used forward pagination cursor""" return self._data['param'].get('after') async def __anext__(self) -> T: if not hasattr(self, self._data['iter_field']) or not isinstance(self.__getattribute__(self._data['iter_field']), list): raise ValueError(f'Object is missing {self._data["iter_field"]} attribute of type list') data = self.__getattribute__(self._data['iter_field']) if len(data) > self.__idx: self.__idx += 1 return data[self.__idx - 1] # make request if self._data['param']['after'] is None: raise StopAsyncIteration() _url = build_url(self._data['url'], self._data['param'], remove_none=True, split_lists=self._data['split']) async with ClientSession() as session: response = await self._data['req'](self._data['method'], session, _url, self._data['auth_t'], self._data['auth_s'], self._data['body']) _data = await response.json() _after = _data.get('pagination', {}).get('cursor') self._data['param']['after'] = _after if self._data['in_data']: _data = _data['data'] # refill data merged_annotations = self._get_annotations() for name, cls in merged_annotations.items(): if name not in _data.keys(): continue self.__setattr__(name, TwitchObject._val_by_instance(cls, _data.get(name))) data = self.__getattribute__(self._data['iter_field']) self.__idx = 1 if len(data) == 0: raise StopAsyncIteration() return data[self.__idx - 1] Teekeks-pyTwitchAPI-0d97664/twitchAPI/object/eventsub.py000066400000000000000000002014341463733066200230620ustar00rootroot00000000000000# Copyright (c) 2023. Lena "Teekeks" During """ Objects used by EventSub ------------------------ """ from twitchAPI.object.base import TwitchObject from datetime import datetime from typing import List, Optional __all__ = ['ChannelPollBeginEvent', 'ChannelUpdateEvent', 'ChannelFollowEvent', 'ChannelSubscribeEvent', 'ChannelSubscriptionEndEvent', 'ChannelSubscriptionGiftEvent', 'ChannelSubscriptionMessageEvent', 'ChannelCheerEvent', 'ChannelRaidEvent', 'ChannelBanEvent', 'ChannelUnbanEvent', 'ChannelModeratorAddEvent', 'ChannelModeratorRemoveEvent', 'ChannelPointsCustomRewardAddEvent', 'ChannelPointsCustomRewardUpdateEvent', 'ChannelPointsCustomRewardRemoveEvent', 'ChannelPointsCustomRewardRedemptionAddEvent', 'ChannelPointsCustomRewardRedemptionUpdateEvent', 'ChannelPollProgressEvent', 'ChannelPollEndEvent', 'ChannelPredictionEvent', 'ChannelPredictionEndEvent', 'DropEntitlementGrantEvent', 'ExtensionBitsTransactionCreateEvent', 'GoalEvent', 'HypeTrainEvent', 'HypeTrainEndEvent', 'StreamOnlineEvent', 'StreamOfflineEvent', 'UserAuthorizationGrantEvent', 'UserAuthorizationRevokeEvent', 'UserUpdateEvent', 'ShieldModeEvent', 'CharityCampaignStartEvent', 'CharityCampaignProgressEvent', 'CharityCampaignStopEvent', 'CharityDonationEvent', 'ChannelShoutoutCreateEvent', 'ChannelShoutoutReceiveEvent', 'ChannelChatClearEvent', 'ChannelChatClearUserMessagesEvent', 'ChannelChatMessageDeleteEvent', 'ChannelChatNotificationEvent', 'ChannelAdBreakBeginEvent', 'ChannelChatMessageEvent', 'Subscription', 'ChannelPollBeginData', 'PollChoice', 'BitsVoting', 'ChannelPointsVoting', 'ChannelUpdateData', 'ChannelFollowData', 'ChannelSubscribeData', 'ChannelSubscriptionEndData', 'ChannelSubscriptionGiftData', 'ChannelSubscriptionMessageData', 'SubscriptionMessage', 'Emote', 'ChannelCheerData', 'ChannelRaidData', 'ChannelBanData', 'ChannelUnbanData', 'ChannelModeratorAddData', 'ChannelModeratorRemoveData', 'ChannelPointsCustomRewardData', 'GlobalCooldown', 'Image', 'MaxPerStream', 'MaxPerUserPerStream', 'ChannelPointsCustomRewardRedemptionData', 'Reward', 'ChannelPollProgressData', 'ChannelPollEndData', 'ChannelPredictionData', 'Outcome', 'TopPredictors', 'ChannelPredictionEndData', 'DropEntitlementGrantData', 'Entitlement', 'Product', 'ExtensionBitsTransactionCreateData', 'GoalData', 'TopContribution', 'LastContribution', 'HypeTrainData', 'HypeTrainEndData', 'StreamOnlineData', 'StreamOfflineData', 'UserAuthorizationGrantData', 'UserAuthorizationRevokeData', 'UserUpdateData', 'ShieldModeData', 'Amount', 'CharityCampaignStartData', 'CharityCampaignStopData', 'CharityCampaignProgressData', 'CharityDonationData', 'ChannelShoutoutCreateData', 'ChannelShoutoutReceiveData', 'ChannelChatClearData', 'ChannelChatClearUserMessagesData', 'ChannelChatMessageDeleteData', 'Badge', 'MessageFragmentCheermote', 'MessageFragmentEmote', 'MessageFragmentMention', 'MessageFragment', 'Message', 'AnnouncementNoticeMetadata', 'CharityDonationNoticeMetadata', 'BitsBadgeTierNoticeMetadata', 'SubNoticeMetadata', 'RaidNoticeMetadata', 'ResubNoticeMetadata', 'UnraidNoticeMetadata', 'SubGiftNoticeMetadata', 'CommunitySubGiftNoticeMetadata', 'GiftPaidUpgradeNoticeMetadata', 'PrimePaidUpgradeNoticeMetadata', 'PayItForwardNoticeMetadata', 'ChannelChatNotificationData', 'ChannelAdBreakBeginData', 'ChannelChatMessageData', 'ChatMessage', 'ChatMessageBadge', 'ChatMessageFragment', 'ChatMessageFragmentCheermoteMetadata', 'ChatMessageFragmentMentionMetadata', 'ChatMessageReplyMetadata', 'ChatMessageCheerMetadata', 'ChatMessageFragmentEmoteMetadata'] # Event Data class Subscription(TwitchObject): condition: dict cost: int created_at: datetime id: str status: str transport: dict type: str version: str class PollChoice(TwitchObject): id: str """ID for the choice""" title: str """Text displayed for the choice""" bits_votes: int """Not used; will be stet to 0""" channel_points_votes: int """Number of votes received via Channel Points""" votes: int """Total number of votes received for the choice across all methods of voting""" class BitsVoting(TwitchObject): is_enabled: bool """Not used; will be set to False""" amount_per_vote: int """Not used; will be set to 0""" class ChannelPointsVoting(TwitchObject): is_enabled: bool """Indicates if Channel Points can be used for Voting""" amount_per_vote: int """Number of Channel Points required to vote once with Channel Points""" class ChannelPollBeginData(TwitchObject): id: str """ID of the poll""" broadcaster_user_id: str """The requested broadcaster ID""" broadcaster_user_login: str """The requested broadcaster login""" broadcaster_user_name: str """The requested broadcaster display name""" title: str """Question displayed for the poll""" choices: List[PollChoice] """Array of choices for the poll""" bits_voting: BitsVoting """Not supported""" channel_points_voting: ChannelPointsVoting """The Channel Points voting settings for the Poll""" started_at: datetime """The time the poll started""" ends_at: datetime """The time the poll will end""" class ChannelUpdateData(TwitchObject): broadcaster_user_id: str """The broadcaster’s user ID""" broadcaster_user_login: str """The broadcaster’s user login""" broadcaster_user_name: str """The broadcaster’s user display name""" title: str """The channel’s stream title""" language: str """The channel’s broadcast language""" category_id: str """The channel´s category ID""" category_name: str """The category name""" content_classification_labels: List[str] """Array of classification label IDs currently applied to the Channel""" class ChannelFollowData(TwitchObject): user_id: str """The user ID for the user now following the specified channel""" user_login: str """The user login for the user now following the specified channel""" user_name: str """The user display name for the user now following the specified channel""" broadcaster_user_id: str """The requested broadcaster’s user ID""" broadcaster_user_login: str """The requested broadcaster’s user login""" broadcaster_user_name: str """The requested broadcaster’s user display name""" followed_at: datetime """when the follow occured""" class ChannelSubscribeData(TwitchObject): user_id: str """The user ID for the user who subscribed to the specified channel""" user_login: str """The user login for the user who subscribed to the specified channel""" user_name: str """The user display name for the user who subscribed to the specified channel""" broadcaster_user_id: str """The requested broadcaster’s user ID""" broadcaster_user_login: str """The requested broadcaster’s user login""" broadcaster_user_name: str """The requested broadcaster’s user display name""" tier: str """The tier of the subscription. Valid values are 1000, 2000, and 3000""" is_gift: bool """Whether the subscription is a gift""" class ChannelSubscriptionEndData(TwitchObject): user_id: str """The user ID for the user whose subscription ended""" user_login: str """The user login for the user whose subscription ended""" user_name: str """The user display name for the user whose subscription ended""" broadcaster_user_id: str """The requested broadcaster’s user ID""" broadcaster_user_login: str """The requested broadcaster’s user login""" broadcaster_user_name: str """The requested broadcaster’s user display name""" tier: str """The tier of the subscription that ended. Valid values are 1000, 2000, and 3000""" is_gift: bool """Whether the subscription was a gift""" class ChannelSubscriptionGiftData(TwitchObject): user_id: Optional[str] """The user ID for the user who sent the subscription gift. None if it was an anonymous subscription gift.""" user_login: Optional[str] """The user login for the user who sent the subscription gift. None if it was an anonymous subscription gift.""" user_name: Optional[str] """The user display name for the user who sent the subscription gift. None if it was an anonymous subscription gift.""" broadcaster_user_id: str """The requested broadcaster’s user ID""" broadcaster_user_login: str """The requested broadcaster’s user login""" broadcaster_user_name: str """The requested broadcaster’s user display name""" total: int """The number of subscriptions in teh subscription gift""" tier: str """The tier of the subscription that ended. Valid values are 1000, 2000, and 3000""" cumulative_total: Optional[int] """The number of subscriptions giftet by this user in teh channel. None for anonymous gifts or if the gifter has opted out of sharing this information""" is_anonymous: bool """Whether the subscription gift was anonymous""" class Emote(TwitchObject): begin: int """The index of where the Emote starts in the text""" end: int """The index of where the Emote ends in the text""" id: str """The emote ID""" class SubscriptionMessage(TwitchObject): text: str """the text of the resubscription chat message""" emotes: List[Emote] """An array that includes the emote ID and start and end positions for where the emote appears in the text""" class ChannelSubscriptionMessageData(TwitchObject): user_id: str """The user ID for the user who sent a resubscription chat message""" user_login: str """The user login for the user who sent a resubscription chat message""" user_name: str """The user display name for the user who sent a resubscription chat message""" broadcaster_user_id: str """The requested broadcaster’s user ID""" broadcaster_user_login: str """The requested broadcaster’s user login""" broadcaster_user_name: str """The requested broadcaster’s user display name""" tier: str """The tier of the user´s subscription""" message: SubscriptionMessage """An object that contains the resubscription message and emote information needed to recreate the message.""" cumulative_months: Optional[int] """The number of consecutive months the user’s current subscription has been active. None if the user has opted out of sharing this information.""" duration_months: int """The month duration of the subscription""" class ChannelCheerData(TwitchObject): is_anonymous: bool """Whether the user cheered anonymously or not""" user_id: Optional[str] """The user ID for the user who cheered on the specified channel. None if is_anonymous is True.""" user_login: Optional[str] """The user login for the user who cheered on the specified channel. None if is_anonymous is True.""" user_name: Optional[str] """The user display name for the user who cheered on the specified channel. None if is_anonymous is True.""" broadcaster_user_id: str """The requested broadcaster’s user ID""" broadcaster_user_login: str """The requested broadcaster’s user login""" broadcaster_user_name: str """The requested broadcaster’s user display name""" message: str """The message sent with the cheer""" bits: int """The number of bits cheered""" class ChannelRaidData(TwitchObject): from_broadcaster_user_id: str """The broadcaster id that created the raid""" from_broadcaster_user_login: str """The broadcaster login that created the raid""" from_broadcaster_user_name: str """The broadcaster display name that created the raid""" to_broadcaster_user_id: str """The broadcaster id that received the raid""" to_broadcaster_user_login: str """The broadcaster login that received the raid""" to_broadcaster_user_name: str """The broadcaster display name that received the raid""" viewers: int """The number of viewers in the raid""" class ChannelBanData(TwitchObject): user_id: str """The user ID for the user who was banned on the specified channel""" user_login: str """The user login for the user who was banned on the specified channel""" user_name: str """The user display name for the user who was banned on the specified channel""" broadcaster_user_id: str """The requested broadcaster ID""" broadcaster_user_login: str """The requested broadcaster login""" broadcaster_user_name: str """The requested broadcaster display name""" moderator_user_id: str """The user ID of the issuer of the ban""" moderator_user_login: str """The user login of the issuer of the ban""" moderator_user_name: str """The user display name of the issuer of the ban""" reason: str """The reason behind the ban""" banned_at: datetime """The timestamp of when the user was banned or put in a timeout""" ends_at: Optional[datetime] """The timestamp of when the timeout ends. None if the user was banned instead of put in a timeout.""" is_permanent: bool """Indicates whether the ban is permanent (True) or a timeout (False). If True, ends_at will be None.""" class ChannelUnbanData(TwitchObject): user_id: str """The user ID for the user who was unbanned on the specified channel""" user_login: str """The user login for the user who was unbanned on the specified channel""" user_name: str """The user display name for the user who was unbanned on the specified channel""" broadcaster_user_id: str """The requested broadcaster ID""" broadcaster_user_login: str """The requested broadcaster login""" broadcaster_user_name: str """The requested broadcaster display name""" moderator_user_id: str """The user ID of the issuer of the unban""" moderator_user_login: str """The user login of the issuer of the unban""" moderator_user_name: str """The user display name of the issuer of the unban""" class ChannelModeratorAddData(TwitchObject): broadcaster_user_id: str """The requested broadcaster ID""" broadcaster_user_login: str """The requested broadcaster login""" broadcaster_user_name: str """The requested broadcaster display name""" user_id: str """The user ID of the new moderator""" user_login: str """The user login of the new moderator""" user_name: str """The user display name of the new moderator""" class ChannelModeratorRemoveData(TwitchObject): broadcaster_user_id: str """The requested broadcaster ID""" broadcaster_user_login: str """The requested broadcaster login""" broadcaster_user_name: str """The requested broadcaster display name""" user_id: str """The user ID of the removed moderator""" user_login: str """The user login of the removed moderator""" user_name: str """The user display name of the removed moderator""" class MaxPerStream(TwitchObject): is_enabled: bool """Is the setting enabled""" value: int """The max per stream limit""" class MaxPerUserPerStream(TwitchObject): is_enabled: bool """Is the setting enabled""" value: int """The max per user per stream limit""" class Image(TwitchObject): url_1x: str """URL for the image at 1x size""" url_2x: str """URL for the image at 2x size""" url_4x: str """URL for the image at 4x size""" class GlobalCooldown(TwitchObject): is_enabled: bool """Is the setting enabled""" seconds: int """The cooldown in seconds""" class ChannelPointsCustomRewardData(TwitchObject): id: str """The reward identifier""" broadcaster_user_id: str """The requested broadcaster ID""" broadcaster_user_login: str """The requested broadcaster login""" broadcaster_user_name: str """The requested broadcaster display name""" is_enabled: bool """Is the reward currently enabled. If False, the reward won't show up to viewers.""" is_paused: bool """Is the reward currently paused. If True, viewers can't redeem.""" is_in_stock: bool """Is the reward currently in stock. If False, viewers can't redeem.""" title: str """The reward title""" cost: int """The reward cost""" prompt: str """The reward description""" is_user_input_required: bool """Does the viewer need to enter information when redeeming the reward""" should_redemptions_skip_request_queue: bool """Should redemptions be set to :code:`fulfilled` status immediately when redeemed and skip the request queue instead of the normal :code:`unfulfilled` status.""" max_per_stream: MaxPerStream """Whether a maximum per stream is enabled and what the maximum is""" max_per_user_per_stream: MaxPerUserPerStream """Whether a maximum per user per stream is enabled and what the maximum is""" background_color: str """Custom background color for the reward. Format: Hex with # prefix.""" image: Optional[Image] """Set of custom images for the reward. None if no images have been uploaded""" default_image: Image """Set of default images for the reward""" global_cooldown: GlobalCooldown """Whether a cooldown is enabled and what the cooldown is in seconds""" cooldown_expires_at: Optional[datetime] """Timestamp of the cooldown expiration. None if the reward is not on cooldown.""" redemptions_redeemed_current_stream: Optional[int] """The number of redemptions redeemed during the current live stream. Counts against the max_per_stream limit. None if the broadcasters stream is not live or max_per_stream isn not enabled.""" class Reward(TwitchObject): id: str """The reward identifier""" title: str """The reward name""" cost: int """The reward cost""" prompt: str """The reward description""" class ChannelPointsCustomRewardRedemptionData(TwitchObject): id: str """The redemption identifier""" broadcaster_user_id: str """The requested broadcaster ID""" broadcaster_user_login: str """The requested broadcaster login""" broadcaster_user_name: str """The requested broadcaster display name""" user_id: str """User ID of the user the redeemed the reward""" user_login: str """Login of the user the redeemed the reward""" user_name: str """Display name of the user the redeemed the reward""" user_input: str """The user input provided. Empty if not provided.""" status: str """Defaults to :code:`unfulfilled`. Possible values are: :code:`unknown`, :code:`unfulfilled`, :code:`fulfilled` and :code:`canceled`""" reward: Reward """Basic information about the reward that was redeemed, at the time it was redeemed""" redeemed_at: datetime """Timestamp of when the reward was redeemed""" class ChannelPollProgressData(TwitchObject): id: str """ID of the poll""" broadcaster_user_id: str """The requested broadcaster ID""" broadcaster_user_login: str """The requested broadcaster login""" broadcaster_user_name: str """The requested broadcaster display name""" title: str """Question displayed for the poll""" choices: List[PollChoice] """An array of choices for the poll. Includes vote counts.""" bits_voting: BitsVoting """not supported""" channel_points_voting: ChannelPointsVoting """The Channel Points voting settings for the poll""" started_at: datetime """The time the poll started""" ends_at: datetime """The time the poll will end""" class ChannelPollEndData(TwitchObject): id: str """ID of the poll""" broadcaster_user_id: str """The requested broadcaster ID""" broadcaster_user_login: str """The requested broadcaster login""" broadcaster_user_name: str """The requested broadcaster display name""" title: str """Question displayed for the poll""" choices: List[PollChoice] """An array of choices for the poll. Includes vote counts.""" bits_voting: BitsVoting """not supported""" channel_points_voting: ChannelPointsVoting """The Channel Points voting settings for the poll""" status: str """The status of the poll. Valid values are completed, archived and terminated.""" started_at: datetime """The time the poll started""" ends_at: datetime """The time the poll ended""" class TopPredictors(TwitchObject): user_id: str """The ID of the user.""" user_login: str """The login of the user.""" user_name: str """The display name of the user.""" channel_points_won: int """The number of Channel Points won. This value is always null in the event payload for Prediction progress and Prediction lock. This value is 0 if the outcome did not win or if the Prediction was canceled and Channel Points were refunded.""" channel_points_used: int """The number of Channel Points used to participate in the Prediction.""" class Outcome(TwitchObject): id: str """The outcome ID.""" title: str """The outcome title.""" color: str """The color for the outcome. Valid values are pink and blue.""" users: int """The number of users who used Channel Points on this outcome.""" channel_points: int """The total number of Channel Points used on this outcome.""" top_predictors: List[TopPredictors] """An array of users who used the most Channel Points on this outcome.""" class ChannelPredictionData(TwitchObject): id: str """Channel Points Prediction ID.""" broadcaster_user_id: str """The requested broadcaster ID.""" broadcaster_user_login: str """The requested broadcaster login.""" broadcaster_user_name: str """The requested broadcaster display name.""" title: str """Title for the Channel Points Prediction.""" outcomes: List[Outcome] """An array of outcomes for the Channel Points Prediction.""" started_at: datetime """The time the Channel Points Prediction started.""" locks_at: datetime """The time the Channel Points Prediction will automatically lock.""" class ChannelPredictionEndData(TwitchObject): id: str """Channel Points Prediction ID.""" broadcaster_user_id: str """The requested broadcaster ID.""" broadcaster_user_login: str """The requested broadcaster login.""" broadcaster_user_name: str """The requested broadcaster display name.""" title: str """Title for the Channel Points Prediction.""" winning_outcome_id: str """ID of the winning outcome.""" outcomes: List[Outcome] """An array of outcomes for the Channel Points Prediction. Includes top_predictors.""" status: str """The status of the Channel Points Prediction. Valid values are resolved and canceled.""" started_at: datetime """The time the Channel Points Prediction started.""" ended_at: datetime """The time the Channel Points Prediction ended.""" class Entitlement(TwitchObject): organization_id: str """The ID of the organization that owns the game that has Drops enabled.""" category_id: str """Twitch category ID of the game that was being played when this benefit was entitled.""" category_name: str """The category name.""" campaign_id: str """The campaign this entitlement is associated with.""" user_id: str """Twitch user ID of the user who was granted the entitlement.""" user_name: str """The user display name of the user who was granted the entitlement.""" user_login: str """The user login of the user who was granted the entitlement.""" entitlement_id: str """Unique identifier of the entitlement. Use this to de-duplicate entitlements.""" benefit_id: str """Identifier of the Benefit.""" created_at: datetime """UTC timestamp in ISO format when this entitlement was granted on Twitch.""" class DropEntitlementGrantData(TwitchObject): id: str """Individual event ID, as assigned by EventSub. Use this for de-duplicating messages.""" data: Entitlement """Entitlement object""" class Product(TwitchObject): name: str """Product name.""" bits: int """Bits involved in the transaction.""" sku: str """Unique identifier for the product acquired.""" in_development: bool """Flag indicating if the product is in development. If in_development is true, bits will be 0.""" class ExtensionBitsTransactionCreateData(TwitchObject): extension_client_id: str """Client ID of the extension.""" id: str """Transaction ID.""" broadcaster_user_id: str """The transaction’s broadcaster ID.""" broadcaster_user_login: str """The transaction’s broadcaster login.""" broadcaster_user_name: str """The transaction’s broadcaster display name.""" user_id: str """The transaction’s user ID.""" user_login: str """The transaction’s user login.""" user_name: str """The transaction’s user display name.""" product: Product """Additional extension product information.""" class GoalData(TwitchObject): id: str """An ID that identifies this event.""" broadcaster_user_id: str """An ID that uniquely identifies the broadcaster.""" broadcaster_user_name: str """The broadcaster’s display name.""" broadcaster_user_login: str """The broadcaster’s user handle.""" type: str """The type of goal. Possible values are: - follow — The goal is to increase followers. - subscription — The goal is to increase subscriptions. This type shows the net increase or decrease in tier points associated with the subscriptions. - subscription_count — The goal is to increase subscriptions. This type shows the net increase or decrease in the number of subscriptions. - new_subscription — The goal is to increase subscriptions. This type shows only the net increase in tier points associated with the subscriptions (it does not account for users that unsubscribed since the goal started). - new_subscription_count — The goal is to increase subscriptions. This type shows only the net increase in the number of subscriptions (it does not account for users that unsubscribed since the goal started). """ description: str """A description of the goal, if specified. The description may contain a maximum of 40 characters.""" is_achieved: Optional[bool] """A Boolean value that indicates whether the broadcaster achieved their goal. Is true if the goal was achieved; otherwise, false. Only the channel.goal.end event includes this field.""" current_amount: int """The goals current value. The goals type determines how this value is increased or decreased - If type is follow, this field is set to the broadcaster's current number of followers. This number increases with new followers and decreases when users unfollow the broadcaster. - If type is subscription, this field is increased and decreased by the points value associated with the subscription tier. For example, if a tier-two subscription is worth 2 points, this field is increased or decreased by 2, not 1. - If type is subscription_count, this field is increased by 1 for each new subscription and decreased by 1 for each user that unsubscribes. - If type is new_subscription, this field is increased by the points value associated with the subscription tier. For example, if a tier-two subscription is worth 2 points, this field is increased by 2, not 1. - If type is new_subscription_count, this field is increased by 1 for each new subscription.""" target_amount: int """The goal’s target value. For example, if the broadcaster has 200 followers before creating the goal, and their goal is to double that number, this field is set to 400.""" started_at: datetime """The timestamp which indicates when the broadcaster created the goal.""" ended_at: Optional[datetime] """The timestamp which indicates when the broadcaster ended the goal. Only the channel.goal.end event includes this field.""" class TopContribution(TwitchObject): user_id: str """The ID of the user that made the contribution.""" user_login: str """The user’s login name.""" user_name: str """The user’s display name.""" type: str """The contribution method used. Possible values are: - bits — Cheering with Bits. - subscription — Subscription activity like subscribing or gifting subscriptions. - other — Covers other contribution methods not listed.""" total: int """The total amount contributed. If type is bits, total represents the amount of Bits used. If type is subscription, total is 500, 1000, or 2500 to represent tier 1, 2, or 3 subscriptions, respectively.""" class LastContribution(TwitchObject): user_id: str """The ID of the user that made the contribution.""" user_login: str """The user’s login name.""" user_name: str """The user’s display name.""" type: str """The contribution method used. Possible values are: - bits — Cheering with Bits. - subscription — Subscription activity like subscribing or gifting subscriptions. - other — Covers other contribution methods not listed.""" total: int """The total amount contributed. If type is bits, total represents the amount of Bits used. If type is subscription, total is 500, 1000, or 2500 to represent tier 1, 2, or 3 subscriptions, respectively.""" class HypeTrainData(TwitchObject): id: str """The Hype Train ID.""" broadcaster_user_id: str """The requested broadcaster ID.""" broadcaster_user_login: str """The requested broadcaster login.""" broadcaster_user_name: str """The requested broadcaster display name.""" total: int """Total points contributed to the Hype Train.""" progress: int """The number of points contributed to the Hype Train at the current level.""" goal: int """The number of points required to reach the next level.""" top_contributions: List[TopContribution] """The contributors with the most points contributed.""" last_contribution: LastContribution """The most recent contribution.""" level: int """The starting level of the Hype Train.""" started_at: datetime """The time when the Hype Train started.""" expires_at: datetime """The time when the Hype Train expires. The expiration is extended when the Hype Train reaches a new level.""" class HypeTrainEndData(TwitchObject): id: str """The Hype Train ID.""" broadcaster_user_id: str """The requested broadcaster ID.""" broadcaster_user_login: str """The requested broadcaster login.""" broadcaster_user_name: str """The requested broadcaster display name.""" level: int """The final level of the Hype Train.""" total: int """Total points contributed to the Hype Train.""" top_contributions: List[TopContribution] """The contributors with the most points contributed.""" started_at: datetime """The time when the Hype Train started.""" ended_at: datetime """The time when the Hype Train ended.""" cooldown_ends_at: datetime """The time when the Hype Train cooldown ends so that the next Hype Train can start.""" class StreamOnlineData(TwitchObject): id: str """The id of the stream.""" broadcaster_user_id: str """The broadcaster’s user id.""" broadcaster_user_login: str """The broadcaster’s user login.""" broadcaster_user_name: str """The broadcaster’s user display name.""" type: str """The stream type. Valid values are: live, playlist, watch_party, premiere, rerun.""" started_at: datetime """The timestamp at which the stream went online at.""" class StreamOfflineData(TwitchObject): broadcaster_user_id: str """The broadcaster’s user id.""" broadcaster_user_login: str """The broadcaster’s user login.""" broadcaster_user_name: str """The broadcaster’s user display name.""" class UserAuthorizationGrantData(TwitchObject): client_id: str """The client_id of the application that was granted user access.""" user_id: str """The user id for the user who has granted authorization for your client id.""" user_login: str """The user login for the user who has granted authorization for your client id.""" user_name: str """The user display name for the user who has granted authorization for your client id.""" class UserAuthorizationRevokeData(TwitchObject): client_id: str """The client_id of the application with revoked user access.""" user_id: str """The user id for the user who has revoked authorization for your client id.""" user_login: str """The user login for the user who has revoked authorization for your client id. This is null if the user no longer exists.""" user_name: str """The user display name for the user who has revoked authorization for your client id. This is null if the user no longer exists.""" class UserUpdateData(TwitchObject): user_id: str """The user’s user id.""" user_login: str """The user’s user login.""" user_name: str """The user’s user display name.""" email: str """The user’s email address. The event includes the user’s email address only if the app used to request this event type includes the user:read:email scope for the user; otherwise, the field is set to an empty string. See Create EventSub Subscription.""" email_verified: bool """A Boolean value that determines whether Twitch has verified the user’s email address. Is true if Twitch has verified the email address; otherwise, false. NOTE: Ignore this field if the email field contains an empty string.""" class ShieldModeData(TwitchObject): broadcaster_user_id: str """An ID that identifies the broadcaster whose Shield Mode status was updated.""" broadcaster_user_login: str """The broadcaster’s login name.""" broadcaster_user_name: str """The broadcaster’s display name.""" moderator_user_id: str """An ID that identifies the moderator that updated the Shield Mode’s status. If the broadcaster updated the status, this ID will be the same as broadcaster_user_id.""" moderator_user_login: str """The moderator’s login name.""" moderator_user_name: str """The moderator’s display name.""" started_at: datetime """The timestamp of when the moderator activated Shield Mode. The object includes this field only for channel.shield_mode.begin events.""" ended_at: datetime """The timestamp of when the moderator deactivated Shield Mode. The object includes this field only for channel.shield_mode.end events.""" class Amount(TwitchObject): value: int """The monetary amount. The amount is specified in the currency’s minor unit. For example, the minor units for USD is cents, so if the amount is $5.50 USD, value is set to 550.""" decimal_places: int """The number of decimal places used by the currency. For example, USD uses two decimal places. Use this number to translate value from minor units to major units by using the formula: value / 10^decimal_places""" currency: str """The ISO-4217 three-letter currency code that identifies the type of currency in value.""" class CharityCampaignStartData(TwitchObject): id: str """An ID that identifies the charity campaign.""" broadcaster_id: str """An ID that identifies the broadcaster that’s running the campaign.""" broadcaster_login: str """The broadcaster’s login name.""" broadcaster_name: str """The broadcaster’s display name.""" charity_name: str """The charity’s name.""" charity_description: str """A description of the charity.""" charity_logo: str """A URL to an image of the charity’s logo. The image’s type is PNG and its size is 100px X 100px.""" charity_website: str """A URL to the charity’s website.""" current_amount: Amount """Contains the current amount of donations that the campaign has received.""" target_amount: Amount """Contains the campaign’s target fundraising goal.""" started_at: datetime """The timestamp of when the broadcaster started the campaign.""" class CharityCampaignProgressData(TwitchObject): id: str """An ID that identifies the charity campaign.""" broadcaster_id: str """An ID that identifies the broadcaster that’s running the campaign.""" broadcaster_login: str """The broadcaster’s login name.""" broadcaster_name: str """The broadcaster’s display name.""" charity_name: str """The charity’s name.""" charity_description: str """A description of the charity.""" charity_logo: str """A URL to an image of the charity’s logo. The image’s type is PNG and its size is 100px X 100px.""" charity_website: str """A URL to the charity’s website.""" current_amount: Amount """Contains the current amount of donations that the campaign has received.""" target_amount: Amount """Contains the campaign’s target fundraising goal.""" class CharityCampaignStopData(TwitchObject): id: str """An ID that identifies the charity campaign.""" broadcaster_id: str """An ID that identifies the broadcaster that ran the campaign.""" broadcaster_login: str """The broadcaster’s login name.""" broadcaster_name: str """The broadcaster’s display name.""" charity_name: str """The charity’s name.""" charity_description: str """A description of the charity.""" charity_logo: str """A URL to an image of the charity’s logo. The image’s type is PNG and its size is 100px X 100px.""" charity_website: str """A URL to the charity’s website.""" current_amount: Amount """Contains the final amount of donations that the campaign received.""" target_amount: Amount """Contains the campaign’s target fundraising goal.""" stopped_at: datetime """The timestamp of when the broadcaster stopped the campaign.""" class CharityDonationData(TwitchObject): id: str """An ID that identifies the donation. The ID is unique across campaigns.""" campaign_id: str """An ID that identifies the charity campaign.""" broadcaster_id: str """An ID that identifies the broadcaster that’s running the campaign.""" broadcaster_login: str """The broadcaster’s login name.""" broadcaster_name: str """The broadcaster’s display name.""" user_id: str """An ID that identifies the user that donated to the campaign.""" user_login: str """The user’s login name.""" user_name: str """The user’s display name.""" charity_name: str """The charity’s name.""" charity_description: str """A description of the charity.""" charity_logo: str """A URL to an image of the charity’s logo. The image’s type is PNG and its size is 100px X 100px.""" charity_website: str """A URL to the charity’s website.""" amount: Amount """Contains the amount of money that the user donated.""" class ChannelShoutoutCreateData(TwitchObject): broadcaster_user_id: str """An ID that identifies the broadcaster that sent the Shoutout.""" broadcaster_user_login: str """The broadcaster’s login name.""" broadcaster_user_name: str """The broadcaster’s display name.""" to_broadcaster_user_id: str """An ID that identifies the broadcaster that received the Shoutout.""" to_broadcaster_user_login: str """The broadcaster’s login name.""" to_broadcaster_user_name: str """The broadcaster’s display name.""" moderator_user_id: str """An ID that identifies the moderator that sent the Shoutout. If the broadcaster sent the Shoutout, this ID is the same as the ID in broadcaster_user_id.""" moderator_user_login: str """The moderator’s login name.""" moderator_user_name: str """The moderator’s display name.""" viewer_count: int """The number of users that were watching the broadcaster’s stream at the time of the Shoutout.""" started_at: datetime """The timestamp of when the moderator sent the Shoutout.""" cooldown_ends_at: datetime """The timestamp of when the broadcaster may send a Shoutout to a different broadcaster.""" target_cooldown_ends_at: datetime """The timestamp of when the broadcaster may send another Shoutout to the broadcaster in to_broadcaster_user_id.""" class ChannelShoutoutReceiveData(TwitchObject): broadcaster_user_id: str """An ID that identifies the broadcaster that received the Shoutout.""" broadcaster_user_login: str """The broadcaster’s login name.""" broadcaster_user_name: str """The broadcaster’s display name.""" from_broadcaster_user_id: str """An ID that identifies the broadcaster that sent the Shoutout.""" from_broadcaster_user_login: str """The broadcaster’s login name.""" from_broadcaster_user_name: str """The broadcaster’s display name.""" viewer_count: int """The number of users that were watching the from-broadcaster’s stream at the time of the Shoutout.""" started_at: datetime """The timestamp of when the moderator sent the Shoutout.""" class ChannelChatClearData(TwitchObject): broadcaster_user_id: str """The broadcaster user ID.""" broadcaster_user_name: str """The broadcaster display name.""" broadcaster_user_login: str """The broadcaster login.""" class ChannelChatClearUserMessagesData(TwitchObject): broadcaster_user_id: str """The broadcaster user ID.""" broadcaster_user_name: str """The broadcaster display name.""" broadcaster_user_login: str """The broadcaster login.""" target_user_id: str """The ID of the user that was banned or put in a timeout. All of their messages are deleted.""" target_user_name: str """The user name of the user that was banned or put in a timeout.""" target_user_login: str """The user login of the user that was banned or put in a timeout.""" class ChannelChatMessageDeleteData(TwitchObject): broadcaster_user_id: str """The broadcaster user ID.""" broadcaster_user_name: str """The broadcaster display name.""" broadcaster_user_login: str """The broadcaster login.""" target_user_id: str """The ID of the user whose message was deleted.""" target_user_name: str """The user name of the user whose message was deleted.""" target_user_login: str """The user login of the user whose message was deleted.""" message_id: str """A UUID that identifies the message that was removed.""" class Badge(TwitchObject): set_id: str """An ID that identifies this set of chat badges. For example, Bits or Subscriber.""" id: str """An ID that identifies this version of the badge. The ID can be any value. For example, for Bits, the ID is the Bits tier level, but for World of Warcraft, it could be Alliance or Horde.""" info: str """Contains metadata related to the chat badges in the badges tag. Currently, this tag contains metadata only for subscriber badges, to indicate the number of months the user has been a subscriber.""" class MessageFragmentCheermote(TwitchObject): prefix: str """The name portion of the Cheermote string that you use in chat to cheer Bits. The full Cheermote string is the concatenation of {prefix} + {number of Bits}. For example, if the prefix is “Cheer” and you want to cheer 100 Bits, the full Cheermote string is Cheer100. When the Cheermote string is entered in chat, Twitch converts it to the image associated with the Bits tier that was cheered.""" bits: int """The amount of bits cheered.""" tier: int """The tier level of the cheermote.""" class MessageFragmentEmote(TwitchObject): id: str """An ID that uniquely identifies this emote.""" emote_set_id: str """An ID that identifies the emote set that the emote belongs to.""" owner_id: str """The ID of the broadcaster who owns the emote.""" format: List[str] """The formats that the emote is available in. For example, if the emote is available only as a static PNG, the array contains only static. But if the emote is available as a static PNG and an animated GIF, the array contains static and animated. The possible formats are: - animated — An animated GIF is available for this emote. - static — A static PNG file is available for this emote.""" class MessageFragmentMention(TwitchObject): user_id: str """The user ID of the mentioned user.""" user_name: str """The user name of the mentioned user.""" user_login: str """The user login of the mentioned user.""" class MessageFragment(TwitchObject): type: str """The type of message fragment. Possible values: - text - cheermote - emote - mention""" text: str """Message text in fragment""" cheermote: Optional[MessageFragmentCheermote] """Metadata pertaining to the cheermote.""" emote: Optional[MessageFragmentEmote] """Metadata pertaining to the emote.""" mention: Optional[MessageFragmentMention] """Metadata pertaining to the mention.""" class Message(TwitchObject): text: str """The chat message in plain text.""" fragments: List[MessageFragment] """Ordered list of chat message fragments.""" class SubNoticeMetadata(TwitchObject): sub_tier: str """The type of subscription plan being used. Possible values are: - 1000 — First level of paid or Prime subscription - 2000 — Second level of paid subscription - 3000 — Third level of paid subscription""" is_prime: bool """Indicates if the subscription was obtained through Amazon Prime.""" duration_months: int """The number of months the subscription is for.""" class ResubNoticeMetadata(TwitchObject): cumulative_months: int """The total number of months the user has subscribed.""" duration_months: int """The number of months the subscription is for.""" streak_months: int """Optional. The number of consecutive months the user has subscribed.""" sub_tier: str """The type of subscription plan being used. Possible values are: - 1000 — First level of paid or Prime subscription - 2000 — Second level of paid subscription - 3000 — Third level of paid subscription""" is_prime: bool """Indicates if the resub was obtained through Amazon Prime.""" is_gift: bool """Whether or not the resub was a result of a gift.""" gifter_is_anonymous: Optional[bool] """Optional. Whether or not the gift was anonymous.""" gifter_user_id: Optional[str] """Optional. The user ID of the subscription gifter. None if anonymous.""" gifter_user_name: Optional[str] """Optional. The user name of the subscription gifter. None if anonymous.""" gifter_user_login: Optional[str] """Optional. The user login of the subscription gifter. None if anonymous.""" class SubGiftNoticeMetadata(TwitchObject): duration_months: int """The number of months the subscription is for.""" cumulative_total: Optional[int] """Optional. The amount of gifts the gifter has given in this channel. None if anonymous.""" recipient_user_id: str """The user ID of the subscription gift recipient.""" recipient_user_name: str """The user name of the subscription gift recipient.""" recipient_user_login: str """The user login of the subscription gift recipient.""" sub_tier: str """The type of subscription plan being used. Possible values are: - 1000 — First level of paid subscription - 2000 — Second level of paid subscription - 3000 — Third level of paid subscription""" community_gift_id: Optional[str] """Optional. The ID of the associated community gift. None if not associated with a community gift.""" class CommunitySubGiftNoticeMetadata(TwitchObject): id: str """The ID of the associated community gift.""" total: int """Number of subscriptions being gifted.""" sub_tier: str """The type of subscription plan being used. Possible values are: - 1000 — First level of paid subscription - 2000 — Second level of paid subscription - 3000 — Third level of paid subscription""" cumulative_total: Optional[int] """Optional. The amount of gifts the gifter has given in this channel. None if anonymous.""" class GiftPaidUpgradeNoticeMetadata(TwitchObject): gifter_is_anonymous: bool """Whether the gift was given anonymously.""" gifter_user_id: Optional[str] """Optional. The user ID of the user who gifted the subscription. None if anonymous.""" gifter_user_name: Optional[str] """Optional. The user name of the user who gifted the subscription. None if anonymous.""" gifter_user_login: Optional[str] """Optional. The user login of the user who gifted the subscription. None if anonymous.""" class PrimePaidUpgradeNoticeMetadata(TwitchObject): sub_tier: str """The type of subscription plan being used. Possible values are: - 1000 — First level of paid subscription - 2000 — Second level of paid subscription - 3000 — Third level of paid subscription""" class RaidNoticeMetadata(TwitchObject): user_id: str """The user ID of the broadcaster raiding this channel.""" user_name: str """The user name of the broadcaster raiding this channel.""" user_login: str """The login name of the broadcaster raiding this channel.""" viewer_count: int """The number of viewers raiding this channel from the broadcaster’s channel.""" profile_image_url: str """Profile image URL of the broadcaster raiding this channel.""" class UnraidNoticeMetadata(TwitchObject): pass class PayItForwardNoticeMetadata(TwitchObject): gifter_is_anonymous: bool """Whether the gift was given anonymously.""" gifter_user_id: Optional[str] """Optional. The user ID of the user who gifted the subscription. None if anonymous.""" gifter_user_name: Optional[str] """Optional. The user name of the user who gifted the subscription. None if anonymous.""" gifter_user_login: Optional[str] """Optional. The user login of the user who gifted the subscription. None if anonymous.""" class AnnouncementNoticeMetadata(TwitchObject): color: str """Color of the announcement.""" class CharityDonationNoticeMetadata(TwitchObject): charity_name: str """Name of the charity.""" amount: Amount """An object that contains the amount of money that the user paid.""" class BitsBadgeTierNoticeMetadata(TwitchObject): tier: int """The tier of the Bits badge the user just earned. For example, 100, 1000, or 10000.""" class ChannelChatNotificationData(TwitchObject): broadcaster_user_id: str """The broadcaster user ID.""" broadcaster_user_name: str """The broadcaster display name.""" broadcaster_user_login: str """The broadcaster login.""" chatter_user_id: str """The user ID of the user that sent the message.""" chatter_user_name: str """The user name of the user that sent the message.""" chatter_user_login: str """The user login of the user that sent the message.""" chatter_is_anonymous: bool """Whether or not the chatter is anonymous.""" color: str """The color of the user’s name in the chat room.""" badges: List[Badge] """List of chat badges.""" system_message: str """The message Twitch shows in the chat room for this notice.""" message_id: str """A UUID that identifies the message.""" message: Message """The structured chat message""" notice_type: str """The type of notice. Possible values are: - sub - resub - sub_gift - community_sub_gift - gift_paid_upgrade - prime_paid_upgrade - raid - unraid - pay_it_forward - announcement - bits_badge_tier - charity_donation""" sub: Optional[SubNoticeMetadata] """Information about the sub event. None if notice_type is not sub.""" resub: Optional[ResubNoticeMetadata] """Information about the resub event. None if notice_type is not resub.""" sub_gift: Optional[SubGiftNoticeMetadata] """Information about the gift sub event. None if notice_type is not sub_gift.""" community_sub_gift: Optional[CommunitySubGiftNoticeMetadata] """Information about the community gift sub event. None if notice_type is not community_sub_gift.""" gift_paid_upgrade: Optional[GiftPaidUpgradeNoticeMetadata] """Information about the community gift paid upgrade event. None if notice_type is not gift_paid_upgrade.""" prime_paid_upgrade: Optional[PrimePaidUpgradeNoticeMetadata] """Information about the Prime gift paid upgrade event. None if notice_type is not prime_paid_upgrade.""" raid: Optional[RaidNoticeMetadata] """Information about the raid event. None if notice_type is not raid.""" unraid: Optional[UnraidNoticeMetadata] """Returns an empty payload if notice_type is unraid, otherwise returns None.""" pay_it_forward: Optional[PayItForwardNoticeMetadata] """Information about the pay it forward event. None if notice_type is not pay_it_forward.""" announcement: Optional[AnnouncementNoticeMetadata] """Information about the announcement event. None if notice_type is not announcement""" charity_donation: Optional[CharityDonationNoticeMetadata] """Information about the charity donation event. None if notice_type is not charity_donation.""" bits_badge_tier: Optional[BitsBadgeTierNoticeMetadata] """Information about the bits badge tier event. None if notice_type is not bits_badge_tier.""" class ChannelAdBreakBeginData(TwitchObject): duration_seconds: int """Length in seconds of the mid-roll ad break requested""" started_at: datetime """The UTC timestamp of when the ad break began, in RFC3339 format. Note that there is potential delay between this event, when the streamer requested the ad break, and when the viewers will see ads.""" is_automatic: bool """Indicates if the ad was automatically scheduled via Ads Manager""" broadcaster_user_id: str """The broadcaster’s user ID for the channel the ad was run on.""" broadcaster_user_login: str """The broadcaster’s user login for the channel the ad was run on.""" broadcaster_user_name: str """The broadcaster’s user display name for the channel the ad was run on.""" requester_user_id: str """The ID of the user that requested the ad. For automatic ads, this will be the ID of the broadcaster.""" requester_user_login: str """The login of the user that requested the ad.""" requester_user_name: str """The display name of the user that requested the ad.""" class ChatMessageFragmentCheermoteMetadata(TwitchObject): prefix: str """The name portion of the Cheermote string that you use in chat to cheer Bits. The full Cheermote string is the concatenation of {prefix} + {number of Bits}. For example, if the prefix is “Cheer” and you want to cheer 100 Bits, the full Cheermote string is Cheer100. When the Cheermote string is entered in chat, Twitch converts it to the image associated with the Bits tier that was cheered.""" bits: int """The amount of bits cheered.""" tier: int """The tier level of the cheermote.""" class ChatMessageFragmentEmoteMetadata(TwitchObject): id: str """An ID that uniquely identifies this emote.""" emote_set_id: str """An ID that identifies the emote set that the emote belongs to.""" owner_id: str """The ID of the broadcaster who owns the emote.""" format: str """ The formats that the emote is available in. For example, if the emote is available only as a static PNG, the array contains only static. But if the emote is available as a static PNG and an animated GIF, the array contains static and animated. The possible formats are: - animated — An animated GIF is available for this emote. - static — A static PNG file is available for this emote.""" class ChatMessageFragmentMentionMetadata(TwitchObject): user_id: str """The user ID of the mentioned user.""" user_name: str """The user name of the mentioned user.""" user_login: str """The user login of the mentioned user.""" class ChatMessageFragment(TwitchObject): type: str """ The type of message fragment. Possible values: - text - cheermote - emote - mention""" text: str """Message text in fragment.""" cheermote: Optional[ChatMessageFragmentCheermoteMetadata] """Optional. Metadata pertaining to the cheermote.""" emote: Optional[ChatMessageFragmentEmoteMetadata] """Optional. Metadata pertaining to the emote.""" mention: Optional[ChatMessageFragmentMentionMetadata] """Optional. Metadata pertaining to the mention.""" class ChatMessageBadge(TwitchObject): set_id: str """An ID that identifies this set of chat badges. For example, Bits or Subscriber.""" id: str """An ID that identifies this version of the badge. The ID can be any value. For example, for Bits, the ID is the Bits tier level, but for World of Warcraft, it could be Alliance or Horde.""" info: str """Contains metadata related to the chat badges in the badges tag. Currently, this tag contains metadata only for subscriber badges, to indicate the number of months the user has been a subscriber.""" class ChatMessageCheerMetadata(TwitchObject): bits: int """The amount of Bits the user cheered.""" class ChatMessageReplyMetadata(TwitchObject): parent_message_id: str """An ID that uniquely identifies the parent message that this message is replying to.""" parent_message_body: str """The message body of the parent message.""" parent_user_id: str """User ID of the sender of the parent message.""" parent_user_name: str """User name of the sender of the parent message.""" parent_user_login: str """User login of the sender of the parent message.""" thread_message_id: str """An ID that identifies the parent message of the reply thread.""" thread_user_id: str """User ID of the sender of the thread’s parent message.""" thread_user_name: str """User name of the sender of the thread’s parent message.""" thread_user_login: str """User login of the sender of the thread’s parent message.""" class ChatMessage(TwitchObject): text: str """The chat message in plain text.""" fragments: List[ChatMessageFragment] """Ordered list of chat message fragments.""" class ChannelChatMessageData(TwitchObject): broadcaster_user_id: str """The broadcaster user ID.""" broadcaster_user_name: str """The broadcaster display name.""" broadcaster_user_login: str """The broadcaster login.""" chatter_user_id: str """The user ID of the user that sent the message.""" chatter_user_name: str """The user name of the user that sent the message.""" chatter_user_login: str """The user login of the user that sent the message.""" message_id: str """A UUID that identifies the message.""" message: ChatMessage """The structured chat message.""" message_type: str """ The type of message. Possible values: - text - channel_points_highlighted - channel_points_sub_only - user_intro""" badges: List[ChatMessageBadge] """List of chat badges.""" cheer: Optional[ChatMessageCheerMetadata] """Optional. Metadata if this message is a cheer.""" color: str """The color of the user’s name in the chat room. This is a hexadecimal RGB color code in the form, #. This tag may be empty if it is never set.""" reply: Optional[ChatMessageReplyMetadata] """Optional. Metadata if this message is a reply.""" channel_points_custom_reward_id: str """Optional. The ID of a channel points custom reward that was redeemed.""" # Events class ChannelPollBeginEvent(TwitchObject): subscription: Subscription event: ChannelPollBeginData class ChannelUpdateEvent(TwitchObject): subscription: Subscription event: ChannelUpdateData class ChannelFollowEvent(TwitchObject): subscription: Subscription event: ChannelFollowData class ChannelSubscribeEvent(TwitchObject): subscription: Subscription event: ChannelSubscribeData class ChannelSubscriptionEndEvent(TwitchObject): subscription: Subscription event: ChannelSubscribeData class ChannelSubscriptionGiftEvent(TwitchObject): subscription: Subscription event: ChannelSubscriptionGiftData class ChannelSubscriptionMessageEvent(TwitchObject): subscription: Subscription event: ChannelSubscriptionMessageData class ChannelCheerEvent(TwitchObject): subscription: Subscription event: ChannelCheerData class ChannelRaidEvent(TwitchObject): subscription: Subscription event: ChannelRaidData class ChannelBanEvent(TwitchObject): subscription: Subscription event: ChannelBanData class ChannelUnbanEvent(TwitchObject): subscription: Subscription event: ChannelUnbanData class ChannelModeratorAddEvent(TwitchObject): subscription: Subscription event: ChannelModeratorAddData class ChannelModeratorRemoveEvent(TwitchObject): subscription: Subscription event: ChannelModeratorRemoveData class ChannelPointsCustomRewardAddEvent(TwitchObject): subscription: Subscription event: ChannelPointsCustomRewardData class ChannelPointsCustomRewardUpdateEvent(TwitchObject): subscription: Subscription event: ChannelPointsCustomRewardData class ChannelPointsCustomRewardRemoveEvent(TwitchObject): subscription: Subscription event: ChannelPointsCustomRewardData class ChannelPointsCustomRewardRedemptionAddEvent(TwitchObject): subscription: Subscription event: ChannelPointsCustomRewardRedemptionData class ChannelPointsCustomRewardRedemptionUpdateEvent(TwitchObject): subscription: Subscription event: ChannelPointsCustomRewardRedemptionData class ChannelPollProgressEvent(TwitchObject): subscription: Subscription event: ChannelPollProgressData class ChannelPollEndEvent(TwitchObject): subscription: Subscription event: ChannelPollEndData class ChannelPredictionEvent(TwitchObject): subscription: Subscription event: ChannelPredictionData class ChannelPredictionEndEvent(TwitchObject): subscription: Subscription event: ChannelPredictionEndData class DropEntitlementGrantEvent(TwitchObject): subscription: Subscription event: DropEntitlementGrantData class ExtensionBitsTransactionCreateEvent(TwitchObject): subscription: Subscription event: ExtensionBitsTransactionCreateData class GoalEvent(TwitchObject): subscription: Subscription event: GoalData class HypeTrainEvent(TwitchObject): subscription: Subscription event: HypeTrainData class HypeTrainEndEvent(TwitchObject): subscription: Subscription event: HypeTrainEndData class StreamOnlineEvent(TwitchObject): subscription: Subscription event: StreamOnlineData class StreamOfflineEvent(TwitchObject): subscription: Subscription event: StreamOfflineData class UserAuthorizationGrantEvent(TwitchObject): subscription: Subscription event: UserAuthorizationGrantData class UserAuthorizationRevokeEvent(TwitchObject): subscription: Subscription event: UserAuthorizationRevokeData class UserUpdateEvent(TwitchObject): subscription: Subscription event: UserUpdateData class ShieldModeEvent(TwitchObject): subscription: Subscription event: ShieldModeData class CharityCampaignStartEvent(TwitchObject): subscription: Subscription event: CharityCampaignStartData class CharityCampaignProgressEvent(TwitchObject): subscription: Subscription event: CharityCampaignProgressData class CharityCampaignStopEvent(TwitchObject): subscription: Subscription event: CharityCampaignStopData class CharityDonationEvent(TwitchObject): subscription: Subscription event: CharityDonationData class ChannelShoutoutCreateEvent(TwitchObject): subscription: Subscription event: ChannelShoutoutCreateData class ChannelShoutoutReceiveEvent(TwitchObject): subscription: Subscription event: ChannelShoutoutReceiveData class ChannelChatClearEvent(TwitchObject): subscription: Subscription event: ChannelChatClearData class ChannelChatClearUserMessagesEvent(TwitchObject): subscription: Subscription event: ChannelChatClearUserMessagesData class ChannelChatMessageDeleteEvent(TwitchObject): subscription: Subscription event: ChannelChatMessageDeleteData class ChannelChatNotificationEvent(TwitchObject): subscription: Subscription event: ChannelChatNotificationData class ChannelAdBreakBeginEvent(TwitchObject): subscription: Subscription event: ChannelAdBreakBeginData class ChannelChatMessageEvent(TwitchObject): subscription: Subscription event: ChannelChatMessageData Teekeks-pyTwitchAPI-0d97664/twitchAPI/pubsub.py000066400000000000000000000754231463733066200212700ustar00rootroot00000000000000# Copyright (c) 2020. Lena "Teekeks" During """ PubSub ------ This is a full implementation of the PubSub API of twitch. PubSub enables you to subscribe to a topic, for updates (e.g., when a user cheers in a channel). Read more about it on `the Twitch API Documentation `__. .. note:: You **always** need User Authentication while using this! ************ Code Example ************ .. code-block:: python from twitchAPI.pubsub import PubSub from twitchAPI.twitch import Twitch from twitchAPI.helper import first from twitchAPI.type import AuthScope from twitchAPI.oauth import UserAuthenticator import asyncio from pprint import pprint from uuid import UUID APP_ID = 'my_app_id' APP_SECRET = 'my_app_secret' USER_SCOPE = [AuthScope.WHISPERS_READ] TARGET_CHANNEL = 'teekeks42' async def callback_whisper(uuid: UUID, data: dict) -> None: print('got callback for UUID ' + str(uuid)) pprint(data) async def run_example(): # setting up Authentication and getting your user id twitch = await Twitch(APP_ID, APP_SECRET) auth = UserAuthenticator(twitch, [AuthScope.WHISPERS_READ], force_verify=False) token, refresh_token = await auth.authenticate() # you can get your user auth token and user auth refresh token following the example in twitchAPI.oauth await twitch.set_user_authentication(token, [AuthScope.WHISPERS_READ], refresh_token) user = await first(twitch.get_users(logins=[TARGET_CHANNEL])) # starting up PubSub pubsub = PubSub(twitch) pubsub.start() # you can either start listening before or after you started pubsub. uuid = await pubsub.listen_whispers(user.id, callback_whisper) input('press ENTER to close...') # you do not need to unlisten to topics before stopping but you can listen and unlisten at any moment you want await pubsub.unlisten(uuid) pubsub.stop() await twitch.close() asyncio.run(run_example()) ******************* Class Documentation ******************* """ from asyncio import CancelledError from functools import partial import aiohttp from aiohttp import ClientSession from .twitch import Twitch from .type import * from .helper import get_uuid, make_enum, TWITCH_PUB_SUB_URL, done_task_callback import asyncio import threading import json from random import randrange import datetime from logging import getLogger, Logger from uuid import UUID from time import sleep from typing import Callable, List, Dict, Awaitable, Optional __all__ = ['PubSub'] CALLBACK_FUNC = Callable[[UUID, dict], Awaitable[None]] class PubSub: """The PubSub client """ def __init__(self, twitch: Twitch, callback_loop: Optional[asyncio.AbstractEventLoop] = None): """ :param twitch: A authenticated Twitch instance :param callback_loop: The asyncio eventloop to be used for callbacks. \n Set this if you or a library you use cares about which asyncio event loop is running the callbacks. Defaults to the one used by PubSub. """ self.__twitch: Twitch = twitch self.logger: Logger = getLogger('twitchAPI.pubsub') """The logger used for PubSub related log messages""" self.ping_frequency: int = 120 """With which frequency in seconds a ping command is send. You probably don't want to change this. This should never be shorter than 12 + `ping_jitter` seconds to avoid problems with the pong timeout. |default| :code:`120`""" self.ping_jitter: int = 4 """time in seconds added or subtracted from `ping_frequency`. You probably don't want to change this. |default| :code:`4`""" self.listen_confirm_timeout: int = 30 """maximum time in seconds waited for a listen confirm. |default| :code:`30`""" self.reconnect_delay_steps: List[int] = [1, 2, 4, 8, 16, 32, 64, 128] self.__connection = None self._callback_loop = callback_loop self.__socket_thread: Optional[threading.Thread] = None self.__running: bool = False self.__socket_loop = None self.__topics: dict = {} self._session = None self.__startup_complete: bool = False self.__tasks = None self.__waiting_for_pong: bool = False self.__nonce_waiting_confirm: dict = {} self._closing = False self._task_callback = partial(done_task_callback, self.logger) def start(self) -> None: """ Start the PubSub Client :raises RuntimeError: if already started """ self.logger.debug('starting pubsub...') if self.__running: raise RuntimeError('already started') self.__startup_complete = False self.__socket_thread = threading.Thread(target=self.__run_socket) self.__running = True self.__socket_thread.start() while not self.__startup_complete: sleep(0.01) self.logger.debug('pubsub started up!') async def _stop(self): for t in self.__tasks: t.cancel() await self.__connection.close() await self._session.close() await asyncio.sleep(0.25) self._closing = True def stop(self) -> None: """ Stop the PubSub Client :raises RuntimeError: if the client is not running """ if not self.__running: raise RuntimeError('not running') self.logger.debug('stopping pubsub...') self.__startup_complete = False self.__running = False f = asyncio.run_coroutine_threadsafe(self._stop(), self.__socket_loop) f.result() self.logger.debug('pubsub stopped!') self.__socket_thread.join() def is_connected(self) -> bool: """Returns your current connection status.""" if self.__connection is None: return False return not self.__connection.closed ########################################################################################### # Internal ########################################################################################### async def __connect(self, is_startup=False): self.logger.debug('connecting...') self._closing = False if self.__connection is not None and not self.__connection.closed: await self.__connection.close() retry = 0 need_retry = True if self._session is None: self._session = ClientSession(timeout=self.__twitch.session_timeout) while need_retry and retry < len(self.reconnect_delay_steps): need_retry = False try: self.__connection = await self._session.ws_connect(TWITCH_PUB_SUB_URL) except Exception: self.logger.warning(f'connection attempt failed, retry in {self.reconnect_delay_steps[retry]}s...') await asyncio.sleep(self.reconnect_delay_steps[retry]) retry += 1 need_retry = True if retry >= len(self.reconnect_delay_steps): raise TwitchBackendException('can\'t connect') if not self.__connection.closed and not is_startup: uuid = str(get_uuid()) await self.__send_listen(uuid, list(self.__topics.keys())) async def __send_listen(self, nonce: str, topics: List[str], subscribe: bool = True): listen_msg = { 'type': 'LISTEN' if subscribe else 'UNLISTEN', 'nonce': nonce, 'data': { 'topics': topics, 'auth_token': self.__twitch.get_user_auth_token() } } self.__nonce_waiting_confirm[nonce] = {'received': False, 'error': PubSubResponseError.NONE} timeout = datetime.datetime.utcnow() + datetime.timedelta(seconds=self.listen_confirm_timeout) confirmed = False self.logger.debug(f'sending {"" if subscribe else "un"}listen for topics {str(topics)} with nonce {nonce}') await self.__send_message(listen_msg) # wait for confirm while not confirmed and datetime.datetime.utcnow() < timeout: await asyncio.sleep(0.01) confirmed = self.__nonce_waiting_confirm[nonce]['received'] if not confirmed: raise PubSubListenTimeoutException() else: error = self.__nonce_waiting_confirm[nonce]['error'] if error is not PubSubResponseError.NONE: if error is PubSubResponseError.BAD_AUTH: raise TwitchAuthorizationException() if error is PubSubResponseError.SERVER: raise TwitchBackendException() raise TwitchAPIException(error) async def __send_message(self, msg_data): self.logger.debug(f'sending message {json.dumps(msg_data)}') await self.__connection.send_str(json.dumps(msg_data)) async def _keep_loop_alive(self): while not self._closing: await asyncio.sleep(0.1) def __run_socket(self): self.__socket_loop = asyncio.new_event_loop() asyncio.set_event_loop(self.__socket_loop) if self._callback_loop is None: self._callback_loop = self.__socket_loop # startup self.__socket_loop.run_until_complete(self.__connect(is_startup=True)) self.__tasks = [ asyncio.ensure_future(self.__task_heartbeat(), loop=self.__socket_loop), asyncio.ensure_future(self.__task_receive(), loop=self.__socket_loop), asyncio.ensure_future(self.__task_initial_listen(), loop=self.__socket_loop) ] self.__socket_loop.run_until_complete(self._keep_loop_alive()) async def __generic_listen(self, key, callback_func, required_scopes: List[AuthScope]) -> UUID: if not asyncio.iscoroutinefunction(callback_func): raise ValueError('callback_func needs to be a async function which takes 2 arguments') for scope in required_scopes: if scope not in self.__twitch.get_user_auth_scope(): raise MissingScopeException(str(scope)) uuid = get_uuid() if key not in self.__topics.keys(): self.__topics[key] = {'subs': {}} self.__topics[key]['subs'][uuid] = callback_func if self.__startup_complete: await self.__send_listen(str(uuid), [key]) return uuid ########################################################################################### # Asyncio Tasks ########################################################################################### async def __task_initial_listen(self): self.__startup_complete = True if len(list(self.__topics.keys())) > 0: uuid = str(get_uuid()) await self.__send_listen(uuid, list(self.__topics.keys())) async def __task_heartbeat(self): while not self._closing: next_heartbeat = datetime.datetime.utcnow() + \ datetime.timedelta(seconds=randrange(self.ping_frequency - self.ping_jitter, self.ping_frequency + self.ping_jitter, 1)) while datetime.datetime.utcnow() < next_heartbeat: await asyncio.sleep(1) self.logger.debug('send ping...') pong_timeout = datetime.datetime.utcnow() + datetime.timedelta(seconds=10) self.__waiting_for_pong = True await self.__send_message({'type': 'PING'}) while self.__waiting_for_pong: if datetime.datetime.utcnow() > pong_timeout: self.logger.info('did not receive pong in time, reconnecting...') await self.__connect() self.__waiting_for_pong = False await asyncio.sleep(1) async def __task_receive(self): try: while not self.__connection.closed: message = await self.__connection.receive() if message.type == aiohttp.WSMsgType.TEXT: messages = message.data.split('\r\n') for m in messages: if len(m) == 0: continue self.logger.debug(f'received message {m}') data = json.loads(m) switcher: Dict[str, Callable] = { 'pong': self.__handle_pong, 'reconnect': self.__handle_reconnect, 'response': self.__handle_response, 'message': self.__handle_message, 'auth_revoked': self.__handle_auth_revoked } handler = switcher.get(data.get('type', '').lower(), self.__handle_unknown) self.__socket_loop.create_task(handler(data)) elif message.type == aiohttp.WSMsgType.CLOSED: self.logger.debug('websocket is closing... trying to reestablish connection') try: await self._handle_base_reconnect() except TwitchBackendException: self.logger.exception('Connection to websocket lost and unable to reestablish connection!') break break elif message.type == aiohttp.WSMsgType.ERROR: self.logger.warning('error in websocket') break except CancelledError: return ########################################################################################### # Handler ########################################################################################### async def _handle_base_reconnect(self): await self.__connect(is_startup=False) # noinspection PyUnusedLocal async def __handle_pong(self, data): self.__waiting_for_pong = False self.logger.debug('received pong') # noinspection PyUnusedLocal async def __handle_reconnect(self, data): self.logger.info('received reconnect command, reconnecting now...') await self.__connect() async def __handle_response(self, data): error = make_enum(data.get('error'), PubSubResponseError, PubSubResponseError.UNKNOWN) self.logger.debug(f'got response for nonce {data.get("nonce")}: {str(error)}') self.__nonce_waiting_confirm[data.get('nonce')]['error'] = error self.__nonce_waiting_confirm[data.get('nonce')]['received'] = True async def __handle_message(self, data): topic_data = self.__topics.get(data.get('data', {}).get('topic', ''), None) msg_data = json.loads(data.get('data', {}).get('message', '{}')) if topic_data is not None: for uuid, sub in topic_data.get('subs', {}).items(): t = self._callback_loop.create_task(sub(uuid, msg_data)) t.add_done_callback(self._task_callback) async def __handle_auth_revoked(self, data): revoked_topics = data.get('data', {}).get('topics', []) for topic in revoked_topics: self.__topics.pop(topic, None) self.logger.warning("received auth revoked. no longer listening on topics: " + str(revoked_topics)) async def __handle_unknown(self, data): self.logger.warning('got message of unknown type: ' + str(data)) ########################################################################################### # Listener ########################################################################################### async def unlisten(self, uuid: UUID) -> None: """ Stop listening to a specific Topic subscription. :param ~uuid.UUID uuid: The UUID of the subscription you want to stop listening to :raises ~twitchAPI.type.TwitchAuthorizationException: if Token is not valid :raises ~twitchAPI.type.TwitchBackendException: if the Twitch Server has a problem :raises ~twitchAPI.type.TwitchAPIException: if the server response is something else than suspected :raises ~twitchAPI.type.PubSubListenTimeoutException: if the unsubscription is not confirmed in the time set by `listen_confirm_timeout` """ clear_topics = [] for topic, topic_data in self.__topics.items(): if uuid in topic_data['subs'].keys(): topic_data['subs'].pop(uuid) if len(topic_data['subs'].keys()) == 0: clear_topics.append(topic) if self.__startup_complete and len(clear_topics) > 0: await self.__send_listen(str(uuid), clear_topics, subscribe=False) if len(clear_topics) > 0: for topic in clear_topics: self.__topics.pop(topic) async def listen_whispers(self, user_id: str, callback_func: CALLBACK_FUNC) -> UUID: """ You are notified when anyone whispers the specified user or the specified user whispers to anyone.\n Requires the :const:`~twitchAPI.type.AuthScope.WHISPERS_READ` AuthScope.\n :param user_id: ID of the User :param callback_func: Function called on event :return: UUID of this subscription :raises ~twitchAPI.type.TwitchAuthorizationException: if Token is not valid :raises ~twitchAPI.type.TwitchBackendException: if the Twitch Server has a problem :raises ~twitchAPI.type.TwitchAPIException: if the subscription response is something else than suspected :raises ~twitchAPI.type.PubSubListenTimeoutException: if the subscription is not confirmed in the time set by `listen_confirm_timeout` :raises ~twitchAPI.type.MissingScopeException: if required AuthScope is missing from Token """ return await self.__generic_listen(f'whispers.{user_id}', callback_func, [AuthScope.WHISPERS_READ]) async def listen_bits_v1(self, channel_id: str, callback_func: CALLBACK_FUNC) -> UUID: """ You are notified when anyone cheers in the specified channel.\n Requires the :const:`~twitchAPI.type.AuthScope.BITS_READ` AuthScope.\n :param channel_id: ID of the Channel :param callback_func: Function called on event :return: UUID of this subscription :raises ~twitchAPI.type.TwitchAuthorizationException: if Token is not valid :raises ~twitchAPI.type.TwitchBackendException: if the Twitch Server has a problem :raises ~twitchAPI.type.TwitchAPIException: if the subscription response is something else than suspected :raises ~twitchAPI.type.PubSubListenTimeoutException: if the subscription is not confirmed in the time set by `listen_confirm_timeout` :raises ~twitchAPI.type.MissingScopeException: if required AuthScope is missing from Token """ return await self.__generic_listen(f'channel-bits-events-v1.{channel_id}', callback_func, [AuthScope.BITS_READ]) async def listen_bits(self, channel_id: str, callback_func: CALLBACK_FUNC) -> UUID: """ You are notified when anyone cheers in the specified channel.\n Requires the :const:`~twitchAPI.type.AuthScope.BITS_READ` AuthScope.\n :param channel_id: ID of the Channel :param callback_func: Function called on event :return: UUID of this subscription :raises ~twitchAPI.type.TwitchAuthorizationException: if Token is not valid :raises ~twitchAPI.type.TwitchBackendException: if the Twitch Server has a problem :raises ~twitchAPI.type.TwitchAPIException: if the subscription response is something else than suspected :raises ~twitchAPI.type.PubSubListenTimeoutException: if the subscription is not confirmed in the time set by `listen_confirm_timeout` :raises ~twitchAPI.type.MissingScopeException: if required AuthScope is missing from Token """ return await self.__generic_listen(f'channel-bits-events-v2.{channel_id}', callback_func, [AuthScope.BITS_READ]) async def listen_bits_badge_notification(self, channel_id: str, callback_func: CALLBACK_FUNC) -> UUID: """ You are notified when a user earns a new Bits badge in the given channel, and chooses to share the notification with chat.\n Requires the :const:`~twitchAPI.type.AuthScope.BITS_READ` AuthScope.\n :param channel_id: ID of the Channel :param callback_func: Function called on event :return: UUID of this subscription :raises ~twitchAPI.type.TwitchAuthorizationException: if Token is not valid :raises ~twitchAPI.type.TwitchBackendException: if the Twitch Server has a problem :raises ~twitchAPI.type.TwitchAPIException: if the subscription response is something else than suspected :raises ~twitchAPI.type.PubSubListenTimeoutException: if the subscription is not confirmed in the time set by `listen_confirm_timeout` :raises ~twitchAPI.type.MissingScopeException: if required AuthScope is missing from Token """ return await self.__generic_listen(f'channel-bits-badge-unlocks.{channel_id}', callback_func, [AuthScope.BITS_READ]) async def listen_channel_points(self, channel_id: str, callback_func: CALLBACK_FUNC) -> UUID: """ You are notified when a custom reward is redeemed in the channel.\n Requires the :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_REDEMPTIONS` AuthScope.\n :param channel_id: ID of the Channel :param callback_func: Function called on event :return: UUID of this subscription :raises ~twitchAPI.type.TwitchAuthorizationException: if Token is not valid :raises ~twitchAPI.type.TwitchBackendException: if the Twitch Server has a problem :raises ~twitchAPI.type.TwitchAPIException: if the subscription response is something else than suspected :raises ~twitchAPI.type.PubSubListenTimeoutException: if the subscription is not confirmed in the time set by `listen_confirm_timeout` :raises ~twitchAPI.type.MissingScopeException: if required AuthScope is missing from Token """ return await self.__generic_listen(f'channel-points-channel-v1.{channel_id}', callback_func, [AuthScope.CHANNEL_READ_REDEMPTIONS]) async def listen_channel_subscriptions(self, channel_id: str, callback_func: CALLBACK_FUNC) -> UUID: """ You are notified when anyone subscribes (first month), resubscribes (subsequent months), or gifts a subscription to a channel. Subgift subscription messages contain recipient information.\n Requires the :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_SUBSCRIPTIONS` AuthScope.\n :param channel_id: ID of the Channel :param callback_func: Function called on event :return: UUID of this subscription :raises ~twitchAPI.type.TwitchAuthorizationException: if Token is not valid :raises ~twitchAPI.type.TwitchBackendException: if the Twitch Server has a problem :raises ~twitchAPI.type.TwitchAPIException: if the subscription response is something else than suspected :raises ~twitchAPI.type.PubSubListenTimeoutException: if the subscription is not confirmed in the time set by `listen_confirm_timeout` :raises ~twitchAPI.type.MissingScopeException: if required AuthScope is missing from Token """ return await self.__generic_listen(f'channel-subscribe-events-v1.{channel_id}', callback_func, [AuthScope.CHANNEL_READ_SUBSCRIPTIONS]) async def listen_chat_moderator_actions(self, user_id: str, channel_id: str, callback_func: CALLBACK_FUNC) -> UUID: """ Supports moderators listening to the topic, as well as users listening to the topic to receive their own events. Examples of moderator actions are bans, unbans, timeouts, deleting messages, changing chat mode (followers-only, subs-only), changing AutoMod levels, and adding a mod.\n Requires the :const:`~twitchAPI.type.AuthScope.CHANNEL_MODERATE` AuthScope.\n :param user_id: ID of the User :param channel_id: ID of the Channel :param callback_func: Function called on event :return: UUID of this subscription :raises ~twitchAPI.type.TwitchAuthorizationException: if Token is not valid :raises ~twitchAPI.type.TwitchBackendException: if the Twitch Server has a problem :raises ~twitchAPI.type.TwitchAPIException: if the subscription response is something else than suspected :raises ~twitchAPI.type.PubSubListenTimeoutException: if the subscription is not confirmed in the time set by `listen_confirm_timeout` :raises ~twitchAPI.type.MissingScopeException: if required AuthScope is missing from Token """ return await self.__generic_listen(f'chat_moderator_actions.{user_id}.{channel_id}', callback_func, [AuthScope.CHANNEL_MODERATE]) async def listen_automod_queue(self, moderator_id: str, channel_id: str, callback_func: CALLBACK_FUNC) -> UUID: """ AutoMod flags a message as potentially inappropriate, and when a moderator takes action on a message.\n Requires the :const:`~twitchAPI.type.AuthScope.CHANNEL_MODERATE` AuthScope.\n :param moderator_id: ID of the Moderator :param channel_id: ID of the Channel :param callback_func: Function called on event :return: UUID of this subscription :raises ~twitchAPI.type.TwitchAuthorizationException: if Token is not valid :raises ~twitchAPI.type.TwitchBackendException: if the Twitch Server has a problem :raises ~twitchAPI.type.TwitchAPIException: if the subscription response is something else than suspected :raises ~twitchAPI.type.PubSubListenTimeoutException: if the subscription is not confirmed in the time set by `listen_confirm_timeout` :raises ~twitchAPI.type.MissingScopeException: if required AuthScope is missing from Token """ return await self.__generic_listen(f'automod-queue.{moderator_id}.{channel_id}', callback_func, [AuthScope.CHANNEL_MODERATE]) async def listen_user_moderation_notifications(self, user_id: str, channel_id: str, callback_func: CALLBACK_FUNC) -> UUID: """ A user’s message held by AutoMod has been approved or denied.\n Requires the :const:`~twitchAPI.type.AuthScope.CHAT_READ` AuthScope.\n :param user_id: ID of the User :param channel_id: ID of the Channel :param callback_func: Function called on event :return: UUID of this subscription :raises ~twitchAPI.type.TwitchAuthorizationException: if Token is not valid :raises ~twitchAPI.type.TwitchBackendException: if the Twitch Server has a problem :raises ~twitchAPI.type.TwitchAPIException: if the subscription response is something else than suspected :raises ~twitchAPI.type.PubSubListenTimeoutException: if the subscription is not confirmed in the time set by `listen_confirm_timeout` :raises ~twitchAPI.type.MissingScopeException: if required AuthScope is missing from Token """ return await self.__generic_listen(f'user-moderation-notifications.{user_id}.{channel_id}', callback_func, [AuthScope.CHAT_READ]) async def listen_low_trust_users(self, moderator_id: str, channel_id: str, callback_func: CALLBACK_FUNC) -> UUID: """The broadcaster or a moderator updates the low trust status of a user, or a new message has been sent in chat by a potential ban evader or a bans shared user. Requires the :const:`~twitchAPI.type.AuthScope.CHANNEL_MODERATE` AuthScope.\n :param moderator_id: ID of the moderator :param channel_id: ID of the Channel :param callback_func: Function called on event :return: UUID of this subscription :raises ~twitchAPI.type.TwitchAuthorizationException: if Token is not valid :raises ~twitchAPI.type.TwitchBackendException: if the Twitch Server has a problem :raises ~twitchAPI.type.TwitchAPIException: if the subscription response is something else than suspected :raises ~twitchAPI.type.PubSubListenTimeoutException: if the subscription is not confirmed in the time set by `listen_confirm_timeout` :raises ~twitchAPI.type.MissingScopeException: if required AuthScope is missing from Token """ return await self.__generic_listen(f'low-trust-users.{moderator_id}.{channel_id}', callback_func, [AuthScope.CHANNEL_MODERATE]) async def listen_undocumented_topic(self, topic: str, callback_func: CALLBACK_FUNC) -> UUID: """ Listen to one of the many undocumented PubSub topics. Make sure that you have the required AuthScope for your topic set, since this lib can not check it for you! .. warning:: Using a undocumented topic can break at any time, use at your own risk! :param topic: the topic string :param callback_func: Function called on event :raises ~twitchAPI.type.TwitchAuthorizationException: if Token is not valid or does not have the required AuthScope :raises ~twitchAPI.type.TwitchBackendException: if the Twitch Server has a problem :raises ~twitchAPI.type.TwitchAPIException: if the subscription response is something else than suspected :raises ~twitchAPI.type.PubSubListenTimeoutException: if the subscription is not confirmed in the time set by `listen_confirm_timeout` """ self.logger.warning(f"using undocumented topic {topic}") return await self.__generic_listen(topic, callback_func, []) Teekeks-pyTwitchAPI-0d97664/twitchAPI/py.typed000066400000000000000000000000001463733066200210700ustar00rootroot00000000000000Teekeks-pyTwitchAPI-0d97664/twitchAPI/twitch.py000066400000000000000000007630761463733066200213020ustar00rootroot00000000000000# Copyright (c) 2020. Lena "Teekeks" During """ Twitch API ---------- This is the base of this library, it handles authentication renewal, error handling and permission management. Look at the `Twitch API reference `__ for a more detailed documentation on what each endpoint does. ************* Example Usage ************* .. code-block:: python from twitchAPI.twitch import Twitch from twitchAPI.helper import first import asyncio async def twitch_example(): # initialize the twitch instance, this will by default also create a app authentication for you twitch = await Twitch('app_id', 'app_secret') # call the API for the data of your twitch user # this returns a async generator that can be used to iterate over all results # but we are just interested in the first result # using the first helper makes this easy. user = await first(twitch.get_users(logins='your_twitch_user')) # print the ID of your user or do whatever else you want with it print(user.id) await twitch.close() # run this example asyncio.run(twitch_example()) **************************** Working with the API results **************************** The API returns a few different types of results. TwitchObject ============ A lot of API calls return a child of :py:const:`~twitchAPI.object.TwitchObject` in some way (either directly or via generator). You can always use the :py:const:`~twitchAPI.object.TwitchObject.to_dict()` method to turn that object to a dictionary. Example: .. code-block:: python blocked_term = await twitch.add_blocked_term('broadcaster_id', 'moderator_id', 'bad_word') print(blocked_term.id) IterTwitchObject ================ Some API calls return a special type of TwitchObject. 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 outside of that List. Example: .. code-block:: python lb = await twitch.get_bits_leaderboard() print(lb.total) for e in lb: print(f'#{e.rank:02d} - {e.user_name}: {e.score}') AsyncIterTwitchObject ===================== A few API calls will have usefull data outside of the list the pagination itterates over. For those cases, this object exist. Example: .. code-block:: python schedule = await twitch.get_channel_stream_schedule('user_id') print(schedule.broadcaster_name) async for segment in schedule: print(segment.title) AsyncGenerator ============== AsyncGenerators are used to automatically itterate over all possible resuts of your API call, this will also automatically handle pagination for you. 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 exit conditions in such cases. 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. Example: .. code-block:: python async for tag in twitch.get_all_stream_tags(): print(tag.tag_id) ************** Authentication ************** The Twitch API knows 2 different authentications. App and User Authentication. Which one you need (or if one at all) depends on what calls you want to use. Its 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. App Authentication ================== By default, The lib will try to attempt to create a App Authentication on Initialization: .. code-block:: python from twitchAPI.twitch import Twitch twitch = await Twitch('my_app_id', 'my_app_secret') You can set a Auth Scope like this: .. code-block:: python from twitchAPI.twitch import Twitch, AuthScope twitch = await Twitch('my_app_id', 'my_app_secret', target_app_auth_scope=[AuthScope.USER_EDIT]) If you want to change the AuthScope later use this: .. code-block:: python await twitch.authenticate_app(my_new_scope) If you don't want to use App Authentication, Initialize like this: .. code-block:: python from twitchAPI.twitch import Twitch twitch = await Twitch('my_app_id', authenticate_app=False) User Authentication =================== Only use a user auth token, use this: .. code-block:: python from twitchAPI.twitch import Twitch twitch = await Twitch('my_app_id', authenticate_app=False) # make sure to set the second parameter as the scope used to generate the token await twitch.set_user_authentication('token', [], 'refresh_token') Use both App and user Authentication: .. code-block:: python from twitchAPI.twitch import Twitch twitch = await Twitch('my_app_id', 'my_app_secret') # make sure to set the second parameter as the scope used to generate the token await twitch.set_user_authentication('token', [], 'refresh_token') 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. See :obj:`twitchAPI.oauth` for more info on my build in authenticator. Authentication refresh callback =============================== Optionally you can set a callback for both user access token refresh and app access token refresh. .. code-block:: python from twitchAPI.twitch import Twitch async def user_refresh(token: str, refresh_token: str): print(f'my new user token is: {token}') async def app_refresh(token: str): print(f'my new app token is: {token}') twitch = await Twitch('my_app_id', 'my_app_secret') twitch.app_auth_refresh_callback = app_refresh twitch.user_auth_refresh_callback = user_refresh ******************* Class Documentation ******************* """ import asyncio import aiohttp.helpers from datetime import datetime from aiohttp import ClientSession, ClientResponse from aiohttp.client import ClientTimeout from .helper import TWITCH_API_BASE_URL, TWITCH_AUTH_BASE_URL, build_scope, enum_value_or_none, datetime_to_str, remove_none_values, ResultType, \ build_url from logging import getLogger, Logger from .object.api import * from .type import * from typing import Union, List, Optional, Callable, AsyncGenerator, TypeVar, Dict, Awaitable __all__ = ['Twitch'] T = TypeVar('T') class Twitch: """ Twitch API client """ def __init__(self, app_id: str, app_secret: Optional[str] = None, authenticate_app: bool = True, target_app_auth_scope: Optional[List[AuthScope]] = None, base_url: str = TWITCH_API_BASE_URL, auth_base_url: str = TWITCH_AUTH_BASE_URL, session_timeout: Union[object, ClientTimeout] = aiohttp.helpers.sentinel): """ :param app_id: Your app id :param app_secret: Your app secret, leave as None if you only want to use User Authentication |default| :code:`None` :param authenticate_app: If true, auto generate a app token on startup |default| :code:`True` :param target_app_auth_scope: AuthScope to use if :code:`authenticate_app` is True |default| :code:`None` :param base_url: The URL to the Twitch API |default| :const:`~twitchAPI.helper.TWITCH_API_BASE_URL` :param auth_base_url: The URL to the Twitch API auth server |default| :const:`~twitchAPI.helper.TWITCH_AUTH_BASE_URL` :param session_timeout: Override the time in seconds before any request times out. Defaults to aiohttp default (300 seconds) """ self.app_id: Optional[str] = app_id self.app_secret: Optional[str] = app_secret self.logger: Logger = getLogger('twitchAPI.twitch') """The logger used for Twitch API call related log messages""" self.user_auth_refresh_callback: Optional[Callable[[str, str], Awaitable[None]]] = None """If set, gets called whenever a user auth token gets refreshed. Parameter: Auth Token, Refresh Token |default| :code:`None`""" self.app_auth_refresh_callback: Optional[Callable[[str], Awaitable[None]]] = None """If set, gets called whenever a app auth token gets refreshed. Parameter: Auth Token |default| :code:`None`""" self.session_timeout: Union[object, ClientTimeout] = session_timeout """Override the time in seconds before any request times out. Defaults to aiohttp default (300 seconds)""" self._app_auth_token: Optional[str] = None self._app_auth_scope: List[AuthScope] = [] self._has_app_auth: bool = False self._user_auth_token: Optional[str] = None self._user_auth_refresh_token: Optional[str] = None self._user_auth_scope: List[AuthScope] = [] self._has_user_auth: bool = False self.auto_refresh_auth: bool = True """If set to true, auto refresh the auth token once it expires. |default| :code:`True`""" self._authenticate_app = authenticate_app self._target_app_scope = target_app_auth_scope self.base_url: str = base_url """The URL to the Twitch API used""" self.auth_base_url: str = auth_base_url self._user_token_refresh_lock: bool = False self._app_token_refresh_lock: bool = False def __await__(self): if self._authenticate_app: t = asyncio.create_task(self.authenticate_app(self._target_app_scope if self._target_app_scope is not None else [])) yield from t return self @staticmethod async def close(): """Gracefully close the connection to the Twitch API""" # ensure that asyncio actually gracefully shut down await asyncio.sleep(0.25) def _generate_header(self, auth_type: 'AuthType', required_scope: List[Union[AuthScope, List[AuthScope]]]) -> dict: header = {"Client-ID": self.app_id} if auth_type == AuthType.EITHER: has_auth, target, token, scope = self._get_used_either_auth(required_scope) if not has_auth: raise UnauthorizedException('No authorization with correct scope set!') header['Authorization'] = f'Bearer {token}' elif auth_type == AuthType.APP: if not self._has_app_auth: raise UnauthorizedException('Require app authentication!') for s in required_scope: if isinstance(s, list): if not any([x in self._app_auth_scope for x in s]): raise MissingScopeException(f'Require at least one of the following app auth scopes: {", ".join([x.name for x in s])}') else: if s not in self._app_auth_scope: raise MissingScopeException('Require app auth scope ' + s.name) header['Authorization'] = f'Bearer {self._app_auth_token}' elif auth_type == AuthType.USER: if not self._has_user_auth: raise UnauthorizedException('require user authentication!') for s in required_scope: if isinstance(s, list): if not any([x in self._user_auth_scope for x in s]): raise MissingScopeException(f'Require at least one of the following user auth scopes: {", ".join([x.name for x in s])}') else: if s not in self._user_auth_scope: raise MissingScopeException('Require user auth scope ' + s.name) header['Authorization'] = f'Bearer {self._user_auth_token}' elif auth_type == AuthType.NONE: # set one anyway for better performance if possible but don't error if none found has_auth, target, token, scope = self._get_used_either_auth(required_scope) if has_auth: header['Authorization'] = f'Bearer {token}' return header def _get_used_either_auth(self, required_scope: List[AuthScope]) -> (bool, AuthType, Union[None, str], List[AuthScope]): if self.has_required_auth(AuthType.USER, required_scope): return True, AuthType.USER, self._user_auth_token, self._user_auth_scope if self.has_required_auth(AuthType.APP, required_scope): return True, AuthType.APP, self._app_auth_token, self._app_auth_scope return False, AuthType.NONE, None, [] def get_user_auth_scope(self) -> List[AuthScope]: """Returns the set User auth Scope""" return self._user_auth_scope def has_required_auth(self, required_type: AuthType, required_scope: List[AuthScope]) -> bool: if required_type == AuthType.NONE: return True if required_type == AuthType.EITHER: return self.has_required_auth(AuthType.USER, required_scope) or \ self.has_required_auth(AuthType.APP, required_scope) if required_type == AuthType.USER: if not self._has_user_auth: return False for s in required_scope: if s not in self._user_auth_scope: return False return True if required_type == AuthType.APP: if not self._has_app_auth: return False for s in required_scope: if s not in self._app_auth_scope: return False return True # default to false return False # FIXME rewrite refresh_used_token async def refresh_used_token(self): """Refreshes the currently used token""" if self._has_user_auth: from .oauth import refresh_access_token if self._user_token_refresh_lock: while self._user_token_refresh_lock: await asyncio.sleep(0.1) else: self.logger.debug('refreshing user token') self._user_token_refresh_lock = True self._user_auth_token, self._user_auth_refresh_token = await refresh_access_token(self._user_auth_refresh_token, self.app_id, self.app_secret, auth_base_url=self.auth_base_url) self._user_token_refresh_lock = False if self.user_auth_refresh_callback is not None: await self.user_auth_refresh_callback(self._user_auth_token, self._user_auth_refresh_token) else: await self._refresh_app_token() async def _refresh_app_token(self): if self._app_token_refresh_lock: while self._app_token_refresh_lock: await asyncio.sleep(0.1) else: self._app_token_refresh_lock = True self.logger.debug('refreshing app token') await self._generate_app_token() self._app_token_refresh_lock = False if self.app_auth_refresh_callback is not None: await self.app_auth_refresh_callback(self._app_auth_token) async def _check_request_return(self, session: ClientSession, response: ClientResponse, method: str, url: str, auth_type: 'AuthType', required_scope: List[AuthScope], data: Optional[dict] = None, retries: int = 1 ) -> ClientResponse: if self.auto_refresh_auth and retries > 0: if response.status == 401: # unauthorized, lets try to refresh the token once self.logger.debug('got 401 response -> try to refresh token') await self.refresh_used_token() return await self._api_request(method, session, url, auth_type, required_scope, data=data, retries=retries - 1) elif response.status == 503: # service unavailable, retry exactly once as recommended by twitch documentation self.logger.debug('got 503 response -> retry once') return await self._api_request(method, session, url, auth_type, required_scope, data=data, retries=retries - 1) elif self.auto_refresh_auth and retries <= 0: if response.status == 503: raise TwitchBackendException('The Twitch API returns a server error') if response.status == 401: msg = (await response.json()).get('message', '') self.logger.debug(f'got 401 response and can\'t refresh. Message: "{msg}"') raise UnauthorizedException(msg) if response.status == 500: raise TwitchBackendException('Internal Server Error') if response.status == 400: msg = None try: msg = (await response.json()).get('message') except: pass raise TwitchAPIException('Bad Request' + ('' if msg is None else f' - {str(msg)}')) if response.status == 404: msg = None try: msg = (await response.json()).get('message') except: pass raise TwitchResourceNotFound(msg) if response.status == 429 or str(response.headers.get('Ratelimit-Remaining', '')) == '0': self.logger.warning('reached rate limit, waiting for reset') import time reset = int(response.headers['Ratelimit-Reset']) # wait a tiny bit longer to ensure that we are definitely beyond the rate limit await asyncio.sleep((reset - time.time()) + 0.1) return response async def _api_request(self, method: str, session: ClientSession, url: str, auth_type: 'AuthType', required_scope: List[Union[AuthScope, List[AuthScope]]], data: Optional[dict] = None, retries: int = 1) -> ClientResponse: """Make API request""" headers = self._generate_header(auth_type, required_scope) self.logger.debug(f'making {method} request to {url}') req = await session.request(method, url, headers=headers, json=data) return await self._check_request_return(session, req, method, url, auth_type, required_scope, data, retries) async def _build_generator(self, method: str, url: str, url_params: dict, auth_type: AuthType, auth_scope: List[Union[AuthScope, List[AuthScope]]], return_type: T, body_data: Optional[dict] = None, split_lists: bool = False, error_handler: Optional[Dict[int, BaseException]] = None) -> AsyncGenerator[T, None]: _after = url_params.get('after') _first = True async with ClientSession(timeout=self.session_timeout) as session: while _first or _after is not None: url_params['after'] = _after _url = build_url(self.base_url + url, url_params, remove_none=True, split_lists=split_lists) response = await self._api_request(method, session, _url, auth_type, auth_scope, data=body_data) if error_handler is not None: if response.status in error_handler.keys(): raise error_handler[response.status] data = await response.json() for entry in data.get('data', []): yield return_type(**entry) _after = data.get('pagination', {}).get('cursor') _first = False async def _build_iter_result(self, method: str, url: str, url_params: dict, auth_type: AuthType, auth_scope: List[Union[AuthScope, List[AuthScope]]], return_type: T, body_data: Optional[dict] = None, split_lists: bool = False, iter_field: str = 'data', in_data: bool = False): _url = build_url(self.base_url + url, url_params, remove_none=True, split_lists=split_lists) async with ClientSession(timeout=self.session_timeout) as session: response = await self._api_request(method, session, _url, auth_type, auth_scope, data=body_data) data = await response.json() url_params['after'] = data.get('pagination', {}).get('cursor') if in_data: data = data['data'] cont_data = { 'req': self._api_request, 'method': method, 'url': self.base_url + url, 'param': url_params, 'split': split_lists, 'auth_t': auth_type, 'auth_s': auth_scope, 'body': body_data, 'iter_field': iter_field, 'in_data': in_data } return return_type(cont_data, **data) async def _build_result(self, method: str, url: str, url_params: dict, auth_type: AuthType, auth_scope: List[Union[AuthScope, List[AuthScope]]], return_type: T, body_data: Optional[dict] = None, split_lists: bool = False, get_from_data: bool = True, result_type: ResultType = ResultType.RETURN_TYPE, error_handler: Optional[Dict[int, BaseException]] = None): async with ClientSession(timeout=self.session_timeout) as session: _url = build_url(self.base_url + url, url_params, remove_none=True, split_lists=split_lists) response = await self._api_request(method, session, _url, auth_type, auth_scope, data=body_data) if error_handler is not None: if response.status in error_handler.keys(): raise error_handler[response.status] if result_type == ResultType.STATUS_CODE: return response.status if result_type == ResultType.TEXT: return await response.text() if return_type is not None: data = await response.json() if isinstance(return_type, dict): return data origin = return_type.__origin__ if hasattr(return_type, '__origin__') else None if origin == list: c = return_type.__args__[0] return [x if isinstance(x, c) else c(**x) for x in data['data']] if get_from_data: d = data['data'] if isinstance(d, list): if len(d) == 0: return None return return_type(**d[0]) else: return return_type(**d) else: return return_type(**data) async def _generate_app_token(self) -> None: if self.app_secret is None: raise MissingAppSecretException() params = { 'client_id': self.app_id, 'client_secret': self.app_secret, 'grant_type': 'client_credentials', 'scope': build_scope(self._app_auth_scope) } self.logger.debug('generating fresh app token') url = build_url(self.auth_base_url + 'token', params) async with ClientSession(timeout=self.session_timeout) as session: result = await session.post(url) if result.status != 200: raise TwitchAuthorizationException(f'Authentication failed with code {result.status} ({result.text})') try: data = await result.json() self._app_auth_token = data['access_token'] except ValueError: raise TwitchAuthorizationException('Authentication response did not have a valid json body') except KeyError: raise TwitchAuthorizationException('Authentication response did not contain access_token') async def authenticate_app(self, scope: List[AuthScope]) -> None: """Authenticate with a fresh generated app token :param scope: List of Authorization scopes to use :raises ~twitchAPI.type.TwitchAuthorizationException: if the authentication fails :return: None """ self._app_auth_scope = scope await self._generate_app_token() self._has_app_auth = True async def set_app_authentication(self, token: str, scope: List[AuthScope]): """Set a app token, most likely only used for testing purposes :param token: the app token :param scope: List of Authorization scopes that the given app token has """ self._app_auth_token = token self._app_auth_scope = scope self._has_app_auth = True async def set_user_authentication(self, token: str, scope: List[AuthScope], refresh_token: Optional[str] = None, validate: bool = True): """Set a user token to be used. :param token: the generated user token :param scope: List of Authorization Scopes that the given user token has :param refresh_token: The generated refresh token, has to be provided if :attr:`auto_refresh_auth` is True |default| :code:`None` :param validate: if true, validate the set token for being a user auth token and having the required scope |default| :code:`True` :raises ValueError: if :attr:`auto_refresh_auth` is True but refresh_token is not set :raises ~twitchAPI.type.MissingScopeException: if given token is missing one of the required scopes :raises ~twitchAPI.type.InvalidTokenException: if the given token is invalid or for a different client id """ if refresh_token is None and self.auto_refresh_auth: raise ValueError('refresh_token has to be provided when auto_refresh_auth is True') if scope is None: raise MissingScopeException('scope was not provided') if validate: from .oauth import validate_token, refresh_access_token val_result = await validate_token(token, auth_base_url=self.auth_base_url) if val_result.get('status', 200) == 401 and refresh_token is not None: # try to refresh once and revalidate token, refresh_token = await refresh_access_token(refresh_token, self.app_id, self.app_secret, auth_base_url=self.auth_base_url) if self.user_auth_refresh_callback is not None: await self.user_auth_refresh_callback(token, refresh_token) val_result = await validate_token(token, auth_base_url=self.auth_base_url) if val_result.get('status', 200) == 401: raise InvalidTokenException(val_result.get('message', '')) if 'login' not in val_result or 'user_id' not in val_result: # this is a app token or not valid raise InvalidTokenException('not a user oauth token') if val_result.get('client_id') != self.app_id: raise InvalidTokenException('client id does not match') scopes = val_result.get('scopes', []) for s in scope: if s not in scopes: raise MissingScopeException(f'given token is missing scope {s.value}') self._user_auth_token = token self._user_auth_refresh_token = refresh_token self._user_auth_scope = scope self._has_user_auth = True def get_app_token(self) -> Union[str, None]: """Returns the app token that the api uses or None when not authenticated. :return: app token """ return self._app_auth_token def get_user_auth_token(self) -> Union[str, None]: """Returns the current user auth token, None if no user Authentication is set :return: current user auth token """ return self._user_auth_token async def get_refreshed_user_auth_token(self) -> Union[str, None]: """Validates the current set user auth token and returns it Will reauth if token is invalid """ if self._user_auth_token is None: return None from .oauth import validate_token val_result = await validate_token(self._user_auth_token, auth_base_url=self.auth_base_url) if val_result.get('status', 200) != 200: # refresh token await self.refresh_used_token() return self._user_auth_token async def get_refreshed_app_token(self) -> Optional[str]: if self._app_auth_token is None: return None from .oauth import validate_token val_result = await validate_token(self._app_auth_token, auth_base_url=self.auth_base_url) if val_result.get('status', 200) != 200: await self._refresh_app_token() return self._app_auth_token def get_used_token(self) -> Union[str, None]: """Returns the currently used token, can be either the app or user auth Token or None if no auth is set :return: the currently used auth token or None if no Authentication is set """ # if no auth is set, self.__app_auth_token will be None return self._user_auth_token if self._has_user_auth else self._app_auth_token # ====================================================================================================================== # API calls # ====================================================================================================================== async def get_extension_analytics(self, after: Optional[str] = None, extension_id: Optional[str] = None, first: int = 20, ended_at: Optional[datetime] = None, started_at: Optional[datetime] = None, report_type: Optional[AnalyticsReportType] = None) -> AsyncGenerator[ExtensionAnalytic, None]: """Gets a URL that extension developers can use to download analytics reports (CSV files) for their extensions. The URL is valid for 5 minutes.\n\n Requires User authentication with scope :py:const:`~twitchAPI.type.AuthScope.ANALYTICS_READ_EXTENSION`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-extension-analytics :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param extension_id: If this is specified, the returned URL points to an analytics report for just the specified extension. |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :param ended_at: Ending date/time for returned reports, if this is provided, `started_at` must also be specified. |default| :code:`None` :param started_at: Starting date/time for returned reports, if this is provided, `ended_at` must also be specified. |default| :code:`None` :param report_type: Type of analytics report that is returned |default| :code:`None` :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchResourceNotFound: if the extension specified in extension_id was not found :raises ValueError: When you only supply `started_at` or `ended_at` without the other or when first is not in range 1 to 100 """ if ended_at is not None or started_at is not None: # you have to put in both: if ended_at is None or started_at is None: raise ValueError('you must specify both ended_at and started_at') if started_at > ended_at: raise ValueError('started_at must be before ended_at') if first > 100 or first < 1: raise ValueError('first must be between 1 and 100') url_params = { 'after': after, 'ended_at': datetime_to_str(ended_at), 'extension_id': extension_id, 'first': first, 'started_at': datetime_to_str(started_at), 'type': enum_value_or_none(report_type) } async for y in self._build_generator('GET', 'analytics/extensions', url_params, AuthType.USER, [AuthScope.ANALYTICS_READ_EXTENSION], ExtensionAnalytic): yield y async def get_game_analytics(self, after: Optional[str] = None, first: int = 20, game_id: Optional[str] = None, ended_at: Optional[datetime] = None, started_at: Optional[datetime] = None, report_type: Optional[AnalyticsReportType] = None) -> AsyncGenerator[GameAnalytics, None]: """Gets a URL that game developers can use to download analytics reports (CSV files) for their games. The URL is valid for 5 minutes.\n\n Requires User authentication with scope :py:const:`~twitchAPI.type.AuthScope.ANALYTICS_READ_GAMES`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-game-analytics :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :param game_id: Game ID |default| :code:`None` :param ended_at: Ending date/time for returned reports, if this is provided, `started_at` must also be specified. |default| :code:`None` :param started_at: Starting date/time for returned reports, if this is provided, `ended_at` must also be specified. |default| :code:`None` :param report_type: Type of analytics report that is returned. |default| :code:`None` :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchResourceNotFound: if the game specified in game_id was not found :raises ValueError: When you only supply `started_at` or `ended_at` without the other or when first is not in range 1 to 100 """ if ended_at is not None or started_at is not None: if ended_at is None or started_at is None: raise ValueError('you must specify both ended_at and started_at') if ended_at < started_at: raise ValueError('ended_at must be after started_at') if first > 100 or first < 1: raise ValueError('first must be between 1 and 100') url_params = { 'after': after, 'ended_at': datetime_to_str(ended_at), 'first': first, 'game_id': game_id, 'started_at': datetime_to_str(started_at), 'type': report_type } async for y in self._build_generator('GET', 'analytics/game', url_params, AuthType.USER, [AuthScope.ANALYTICS_READ_GAMES], GameAnalytics): yield y async def get_creator_goals(self, broadcaster_id: str) -> AsyncGenerator[CreatorGoal, None]: """Gets Creator Goal Details for the specified channel. Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_GOALS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-creator-goals :param broadcaster_id: The ID of the broadcaster that created the goals. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ async for y in self._build_generator('GET', 'goals', {'broadcaster_id': broadcaster_id}, AuthType.USER, [AuthScope.CHANNEL_READ_GOALS], CreatorGoal): yield y async def get_bits_leaderboard(self, count: Optional[int] = 10, period: Optional[TimePeriod] = TimePeriod.ALL, started_at: Optional[datetime] = None, user_id: Optional[str] = None) -> BitsLeaderboard: """Gets a ranked list of Bits leaderboard information for an authorized broadcaster.\n\n Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.BITS_READ`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-bits-leaderboard :param count: Number of results to be returned. In range 1 to 100, |default| :code:`10` :param period: Time period over which data is aggregated, |default| :const:`twitchAPI.types.TimePeriod.ALL` :param started_at: Timestamp for the period over which the returned data is aggregated. |default| :code:`None` :param user_id: ID of the user whose results are returned; i.e., the person who paid for the Bits. |default| :code:`None` :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if first is not in range 1 to 100 """ if count > 100 or count < 1: raise ValueError('count must be between 1 and 100') url_params = { 'count': count, 'period': period.value, 'started_at': datetime_to_str(started_at), 'user_id': user_id } return await self._build_result('GET', 'bits/leaderboard', url_params, AuthType.USER, [AuthScope.BITS_READ], BitsLeaderboard, get_from_data=False) async def get_extension_transactions(self, extension_id: str, transaction_id: Optional[Union[str, List[str]]] = None, after: Optional[str] = None, first: int = 20) -> AsyncGenerator[ExtensionTransaction, None]: """Get Extension Transactions allows extension back end servers to fetch a list of transactions that have occurred for their extension across all of Twitch. A transaction is a record of a user exchanging Bits for an in-Extension digital good.\n\n Requires App authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-extension-transactions :param extension_id: ID of the extension to list transactions for. :param transaction_id: Transaction IDs to look up. Can either be a list of str or str |default| :code:`None` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :raises ~twitchAPI.type.UnauthorizedException: if app authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchResourceNotFound: if one or more transaction IDs specified in transaction_id where not found :raises ValueError: if first is not in range 1 to 100 :raises ValueError: if transaction_ids is longer than 100 entries """ if first > 100 or first < 1: raise ValueError("first must be between 1 and 100") if transaction_id is not None and isinstance(transaction_id, list) and len(transaction_id) > 100: raise ValueError('transaction_ids cant be longer than 100 entries') url_param = { 'extension_id': extension_id, 'id': transaction_id, 'after': after, 'first': first } async for y in self._build_generator('GET', 'extensions/transactions', url_param, AuthType.EITHER, [], ExtensionTransaction): yield y async def get_chat_settings(self, broadcaster_id: str, moderator_id: Optional[str] = None) -> ChatSettings: """Gets the broadcaster’s chat settings. Requires App authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-chat-settings :param broadcaster_id: The ID of the broadcaster whose chat settings you want to get :param moderator_id: Required only to access the non_moderator_chat_delay or non_moderator_chat_delay_duration settings |default| :code:`None` :raises ~twitchAPI.type.UnauthorizedException: if app authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems """ url_param = { 'broadcaster_id': broadcaster_id, 'moderator_id': moderator_id } return await self._build_result('GET', 'chat/settings', url_param, AuthType.EITHER, [], ChatSettings) async def update_chat_settings(self, broadcaster_id: str, moderator_id: str, emote_mode: Optional[bool] = None, follower_mode: Optional[bool] = None, follower_mode_duration: Optional[int] = None, non_moderator_chat_delay: Optional[bool] = None, non_moderator_chat_delay_duration: Optional[int] = None, slow_mode: Optional[bool] = None, slow_mode_wait_time: Optional[int] = None, subscriber_mode: Optional[bool] = None, unique_chat_mode: Optional[bool] = None) -> ChatSettings: """Updates the broadcaster’s chat settings. Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_CHAT_SETTINGS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#update-chat-settings :param broadcaster_id: The ID of the broadcaster whose chat settings you want to update. :param moderator_id: The ID of a user that has permission to moderate the broadcaster’s chat room. :param emote_mode: A Boolean value that determines whether chat messages must contain only emotes. |default| :code:`None` :param follower_mode: A Boolean value that determines whether the broadcaster restricts the chat room to followers only, based on how long they’ve followed. |default| :code:`None` :param follower_mode_duration: The length of time, in minutes, that the followers must have followed the broadcaster to participate in the chat room |default| :code:`None` :param non_moderator_chat_delay: A Boolean value that determines whether the broadcaster adds a short delay before chat messages appear in the chat room. |default| :code:`None` :param non_moderator_chat_delay_duration: he amount of time, in seconds, that messages are delayed from appearing in chat. Possible Values: 2, 4 and 6 |default| :code:`None` :param slow_mode: A Boolean value that determines whether the broadcaster limits how often users in the chat room are allowed to send messages. |default| :code:`None` :param slow_mode_wait_time: The amount of time, in seconds, that users need to wait between sending messages |default| :code:`None` :param subscriber_mode: A Boolean value that determines whether only users that subscribe to the broadcaster’s channel can talk in the chat room. |default| :code:`None` :param unique_chat_mode: A Boolean value that determines whether the broadcaster requires users to post only unique messages in the chat room. |default| :code:`None` :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if non_moderator_chat_delay_duration is not one of 2, 4 or 6 """ if non_moderator_chat_delay_duration is not None: if non_moderator_chat_delay_duration not in (2, 4, 6): raise ValueError('non_moderator_chat_delay_duration has to be one of 2, 4 or 6') url_param = { 'broadcaster_id': broadcaster_id, 'moderator_id': moderator_id } body = remove_none_values({ 'emote_mode': emote_mode, 'follower_mode': follower_mode, 'follower_mode_duration': follower_mode_duration, 'non_moderator_chat_delay': non_moderator_chat_delay, 'non_moderator_chat_delay_duration': non_moderator_chat_delay_duration, 'slow_mode': slow_mode, 'slow_mode_wait_time': slow_mode_wait_time, 'subscriber_mode': subscriber_mode, 'unique_chat_mode': unique_chat_mode }) return await self._build_result('PATCH', 'chat/settings', url_param, AuthType.USER, [AuthScope.MODERATOR_MANAGE_CHAT_SETTINGS], ChatSettings, body_data=body) async def create_clip(self, broadcaster_id: str, has_delay: bool = False) -> CreatedClip: """Creates a clip programmatically. This returns both an ID and an edit URL for the new clip.\n\n Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.CLIPS_EDIT`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#create-clip :param broadcaster_id: Broadcaster ID of the stream from which the clip will be made. :param has_delay: If False, the clip is captured from the live stream when the API is called; otherwise, a delay is added before the clip is captured (to account for the brief delay between the broadcaster’s stream and the viewer’s experience of that stream). |default| :code:`False` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchResourceNotFound: if the broadcaster is not live """ param = { 'broadcaster_id': broadcaster_id, 'has_delay': has_delay } return await self._build_result('POST', 'clips', param, AuthType.USER, [AuthScope.CLIPS_EDIT], CreatedClip) async def get_clips(self, broadcaster_id: Optional[str] = None, game_id: Optional[str] = None, clip_id: Optional[List[str]] = None, is_featured: Optional[bool] = None, after: Optional[str] = None, before: Optional[str] = None, ended_at: Optional[datetime] = None, started_at: Optional[datetime] = None, first: int = 20) -> AsyncGenerator[Clip, None]: """Gets clip information by clip ID (one or more), broadcaster ID (one only), or game ID (one only). Clips are returned sorted by view count, in descending order.\n\n Requires App or User authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-clips :param broadcaster_id: ID of the broadcaster for whom clips are returned. |default| :code:`None` :param game_id: ID of the game for which clips are returned. |default| :code:`None` :param clip_id: ID of the clip being queried. Limit: 100. |default| :code:`None` :param is_featured: A Boolean value that determines whether the response includes featured clips. |br| If :code:`True`, returns only clips that are featured. |br| If :code:`False`, returns only clips that aren’t featured. |br| If :code:`None`, all clips are returned. |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param before: Cursor for backward pagination |default| :code:`None` :param ended_at: Ending date/time for returned clips |default| :code:`None` :param started_at: Starting date/time for returned clips |default| :code:`None` :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if you try to query more than 100 clips in one call :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ValueError: if not exactly one of clip_id, broadcaster_id or game_id is given :raises ValueError: if first is not in range 1 to 100 :raises ~twitchAPI.type.TwitchResourceNotFound: if the game specified in game_id was not found """ if clip_id is not None and len(clip_id) > 100: raise ValueError('A maximum of 100 clips can be queried in one call') if not (sum([clip_id is not None, broadcaster_id is not None, game_id is not None]) == 1): raise ValueError('You need to specify exactly one of clip_id, broadcaster_id or game_id') if first < 1 or first > 100: raise ValueError('first must be in range 1 to 100') param = { 'broadcaster_id': broadcaster_id, 'game_id': game_id, 'id': clip_id, 'after': after, 'before': before, 'first': first, 'ended_at': datetime_to_str(ended_at), 'started_at': datetime_to_str(started_at), 'is_featured': is_featured } async for y in self._build_generator('GET', 'clips', param, AuthType.EITHER, [], Clip, split_lists=True): yield y async def get_top_games(self, after: Optional[str] = None, before: Optional[str] = None, first: int = 20) -> AsyncGenerator[Game, None]: """Gets games sorted by number of current viewers on Twitch, most popular first.\n\n Requires App or User authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-top-games :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param before: Cursor for backward pagination |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if app authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if first is not in range 1 to 100 """ if first < 1 or first > 100: raise ValueError('first must be between 1 and 100') param = { 'after': after, 'before': before, 'first': first } async for y in self._build_generator('GET', 'games/top', param, AuthType.EITHER, [], Game): yield y async def get_games(self, game_ids: Optional[List[str]] = None, names: Optional[List[str]] = None, igdb_ids: Optional[List[str]] = None) -> AsyncGenerator[Game, None]: """Gets game information by game ID or name.\n\n Requires User or App authentication. In total, only 100 game ids and names can be fetched at once. For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-games :param game_ids: Game ID |default| :code:`None` :param names: Game Name |default| :code:`None` :param igdb_ids: IGDB ID |default| :code:`None` :raises ~twitchAPI.type.UnauthorizedException: if app authentication is not set or invalid :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if none of game_ids, names or igdb_ids are given or if game_ids, names and igdb_ids are more than 100 entries combined. """ if game_ids is None and names is None and igdb_ids is None: raise ValueError('at least one of game_ids, names or igdb_ids has to be set') if (len(game_ids) if game_ids is not None else 0) + \ (len(names) if names is not None else 0) + \ (len(igdb_ids) if igdb_ids is not None else 0) > 100: raise ValueError('in total, only 100 game_ids, names and igdb_ids can be passed') param = { 'id': game_ids, 'name': names, 'igdb_id': igdb_ids } async for y in self._build_generator('GET', 'games', param, AuthType.EITHER, [], Game, split_lists=True): yield y async def check_automod_status(self, broadcaster_id: str, automod_check_entries: List[AutoModCheckEntry]) -> AsyncGenerator[AutoModStatus, None]: """Determines whether a string message meets the channel’s AutoMod requirements.\n\n Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.MODERATION_READ`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#check-automod-status :param broadcaster_id: Provided broadcaster ID must match the user ID in the user auth token. :param automod_check_entries: The Automod Check Entries :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems """ body = {'data': automod_check_entries} async for y in self._build_generator('POST', 'moderation/enforcements/status', {'broadcaster_id': broadcaster_id}, AuthType.USER, [AuthScope.MODERATION_READ], AutoModStatus, body_data=body): yield y async def get_automod_settings(self, broadcaster_id: str, moderator_id: str) -> AutoModSettings: """Gets the broadcaster’s AutoMod settings. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.MODERATOR_READ_AUTOMOD_SETTINGS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-automod-settings :param broadcaster_id: The ID of the broadcaster whose AutoMod settings you want to get. :param moderator_id: The ID of the broadcaster or a user that has permission to moderate the broadcaster’s chat room. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ param = { "broadcaster_id": broadcaster_id, "moderator_id": moderator_id } error_handler = {403: TwitchAPIException('Forbidden: The user in moderator_id is not one of the broadcaster\'s moderators.')} return await self._build_result('GET', 'moderation/automod/settings', param, AuthType.USER, [AuthScope.MODERATOR_READ_AUTOMOD_SETTINGS], AutoModSettings, error_handler=error_handler) async def update_automod_settings(self, broadcaster_id: str, moderator_id: str, settings: Optional[AutoModSettings] = None, overall_level: Optional[int] = None) -> AutoModSettings: """Updates the broadcaster’s AutoMod settings. You can either set the individual level or the overall level, but not both at the same time. Setting the overall_level parameter in settings will be ignored. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_AUTOMOD_SETTINGS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#update-automod-settings :param broadcaster_id: The ID of the broadcaster whose AutoMod settings you want to update. :param moderator_id: The ID of the broadcaster or a user that has permission to moderate the broadcaster’s chat room. :param settings: If you want to change individual settings, set this. |default|:code:`None` :param overall_level: If you want to change the overall level, set this. |default|:code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if both settings and overall_level are given or none of them are given """ if (settings is not None and overall_level is not None) or (settings is None and overall_level is None): raise ValueError('You have to specify exactly one of settings or oevrall_level') param = { "broadcaster_id": broadcaster_id, "moderator_id": moderator_id } body = settings.to_dict() if settings is not None else {} body['overall_level'] = overall_level return await self._build_result('PUT', 'moderation/automod/settings', param, AuthType.USER, [AuthScope.MODERATOR_MANAGE_AUTOMOD_SETTINGS], AutoModSettings, body_data=remove_none_values(body)) async def get_banned_users(self, broadcaster_id: str, user_id: Optional[str] = None, after: Optional[str] = None, first: Optional[int] = 20, before: Optional[str] = None) -> AsyncGenerator[BannedUser, None]: """Returns all banned and timed-out users in a channel.\n\n Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.MODERATION_READ`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-banned-users :param broadcaster_id: Provided broadcaster ID must match the user ID in the user auth token. :param user_id: Filters the results and only returns a status object for users who are banned in this channel and have a matching user_id. |default| :code:`None` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param before: Cursor for backward pagination |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if first is not in range 1 to 100 """ if first < 1 or first > 100: raise ValueError('first must be in range 1 to 100') param = { 'broadcaster_id': broadcaster_id, 'user_id': user_id, 'after': after, 'first': first, 'before': before } async for y in self._build_generator('GET', 'moderation/banned', param, AuthType.USER, [AuthScope.MODERATION_READ], BannedUser): yield y async def ban_user(self, broadcaster_id: str, moderator_id: str, user_id: str, reason: str, duration: Optional[int] = None) -> BanUserResponse: """Bans a user from participating in a broadcaster’s chat room, or puts them in a timeout. Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_BANNED_USERS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#ban-user :param broadcaster_id: The ID of the broadcaster whose chat room the user is being banned from. :param moderator_id: The ID of a user that has permission to moderate the broadcaster’s chat room. This ID must match the user ID associated with the user OAuth token. :param user_id: The ID of the user to ban or put in a timeout. :param reason: The reason the user is being banned or put in a timeout. The text is user defined and limited to a maximum of 500 characters. :param duration: To ban a user indefinitely, don't set this. Put a user in timeout for the number of seconds specified. Maximum 1_209_600 (2 weeks) |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if duration is set and not between 1 and 1_209_600 :raises ValueError: if reason is not between 1 and 500 characters in length """ if duration is not None and (duration < 1 or duration > 1_209_600): raise ValueError('duration must be either omitted or between 1 and 1209600') if len(reason) < 1 or len(reason) > 500: raise ValueError('reason must be between 1 and 500 characters in length') param = { 'broadcaster_id': broadcaster_id, 'moderator_id': moderator_id } body = { 'data': remove_none_values({ 'duration': duration, 'reason': reason, 'user_id': user_id }) } return await self._build_result('POST', 'moderation/bans', param, AuthType.USER, [AuthScope.MODERATOR_MANAGE_BANNED_USERS], BanUserResponse, body_data=body, get_from_data=True) async def unban_user(self, broadcaster_id: str, moderator_id: str, user_id: str) -> bool: """Removes the ban or timeout that was placed on the specified user Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_BANNED_USERS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#unban-user :param broadcaster_id: The ID of the broadcaster whose chat room the user is banned from chatting in. :param moderator_id: The ID of a user that has permission to moderate the broadcaster’s chat room. This ID must match the user ID associated with the user OAuth token. :param user_id: The ID of the user to remove the ban or timeout from. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems """ param = { 'broadcaster_id': broadcaster_id, 'moderator_id': moderator_id, 'user_id': user_id } return await self._build_result('DELETE', 'moderation/bans', param, AuthType.USER, [AuthScope.MODERATOR_MANAGE_BANNED_USERS], None, result_type=ResultType.STATUS_CODE) == 204 async def get_blocked_terms(self, broadcaster_id: str, moderator_id: str, after: Optional[str] = None, first: Optional[int] = None) -> AsyncGenerator[BlockedTerm, None]: """Gets the broadcaster’s list of non-private, blocked words or phrases. These are the terms that the broadcaster or moderator added manually, or that were denied by AutoMod. Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.MODERATOR_READ_BLOCKED_TERMS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-blocked-terms :param broadcaster_id: The ID of the broadcaster whose blocked terms you’re getting. :param moderator_id: The ID of a user that has permission to moderate the broadcaster’s chat room. This ID must match the user ID associated with the user OAuth token. :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if first is set and not between 1 and 100 """ if first is not None and (first < 1 or first > 100): raise ValueError('first must be between 1 and 100') param = { 'broadcaster_id': broadcaster_id, 'moderator_id': moderator_id, 'first': first, 'after': after } async for y in self._build_generator('GET', 'moderation/blocked_terms', param, AuthType.USER, [AuthScope.MODERATOR_READ_BLOCKED_TERMS], BlockedTerm): yield y async def add_blocked_term(self, broadcaster_id: str, moderator_id: str, text: str) -> BlockedTerm: """Adds a word or phrase to the broadcaster’s list of blocked terms. These are the terms that broadcasters don’t want used in their chat room. Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_BLOCKED_TERMS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#add-blocked-term :param broadcaster_id: The ID of the broadcaster that owns the list of blocked terms. :param moderator_id: The ID of a user that has permission to moderate the broadcaster’s chat room. This ID must match the user ID associated with the user OAuth token. :param text: The word or phrase to block from being used in the broadcaster’s chat room. Between 2 and 500 characters long :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if text is not between 2 and 500 characters long """ if len(text) < 2 or len(text) > 500: raise ValueError('text must have a length between 2 and 500 characters') param = { 'broadcaster_id': broadcaster_id, 'moderator_id': moderator_id } body = {'text': text} return await self._build_result('POST', 'moderation/blocked_terms', param, AuthType.USER, [AuthScope.MODERATOR_MANAGE_BLOCKED_TERMS], BlockedTerm, body_data=body) async def remove_blocked_term(self, broadcaster_id: str, moderator_id: str, term_id: str) -> bool: """Removes the word or phrase that the broadcaster is blocking users from using in their chat room. Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_BLOCKED_TERMS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#remove-blocked-term :param broadcaster_id: The ID of the broadcaster that owns the list of blocked terms. :param moderator_id: The ID of a user that has permission to moderate the broadcaster’s chat room. This ID must match the user ID associated with the user OAuth token. :param term_id: The ID of the blocked term you want to delete. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems """ param = { 'broadcaster_id': broadcaster_id, 'moderator_id': moderator_id, 'id': term_id } return await self._build_result('DELETE', 'moderation/blocked_terms', param, AuthType.USER, [AuthScope.MODERATOR_MANAGE_BLOCKED_TERMS], None, result_type=ResultType.STATUS_CODE) == 204 async def get_moderators(self, broadcaster_id: str, user_ids: Optional[List[str]] = None, first: Optional[int] = 20, after: Optional[str] = None) -> AsyncGenerator[Moderator, None]: """Returns all moderators in a channel.\n\n Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.MODERATION_READ`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-moderators :param broadcaster_id: Provided broadcaster ID must match the user ID in the user auth token. :param user_ids: Filters the results and only returns a status object for users who are moderator in this channel and have a matching user_id. Maximum 100 |default| :code:`None` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if user_ids has more than 100 entries :raises ValueError: if first is not in range 1 to 100 """ if first < 1 or first > 100: raise ValueError('first must be in range 1 to 100') if user_ids is not None and len(user_ids) > 100: raise ValueError('user_ids can only be 100 entries long') param = { 'broadcaster_id': broadcaster_id, 'user_id': user_ids, 'first': first, 'after': after } async for y in self._build_generator('GET', 'moderation/moderators', param, AuthType.USER, [AuthScope.MODERATION_READ], Moderator, split_lists=True): yield y async def create_stream_marker(self, user_id: str, description: Optional[str] = None) -> CreateStreamMarkerResponse: """Creates a marker in the stream of a user specified by user ID.\n\n Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_BROADCAST`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#create-stream-marker :param user_id: ID of the broadcaster in whose live stream the marker is created. :param description: Description of or comments on the marker. Max length is 140 characters. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if description has more than 140 characters :raises ~twitchAPI.type.TwitchResourceNotFound: if the user in user_id is not live, the ID is not valid or has not enabled VODs """ if description is not None and len(description) > 140: raise ValueError('max length for description is 140') body = {'user_id': user_id} if description is not None: body['description'] = description return await self._build_result('POST', 'streams/markers', {}, AuthType.USER, [AuthScope.CHANNEL_MANAGE_BROADCAST], CreateStreamMarkerResponse, body_data=body) async def get_streams(self, after: Optional[str] = None, before: Optional[str] = None, first: int = 20, game_id: Optional[List[str]] = None, language: Optional[List[str]] = None, user_id: Optional[List[str]] = None, user_login: Optional[List[str]] = None, stream_type: Optional[str] = None) -> AsyncGenerator[Stream, None]: """Gets information about active streams. Streams are returned sorted by number of current viewers, in descending order. Across multiple pages of results, there may be duplicate or missing streams, as viewers join and leave streams.\n\n Requires App or User authentication.\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-streams :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param before: Cursor for backward pagination |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :param game_id: Returns streams broadcasting a specified game ID. You can specify up to 100 IDs. |default| :code:`None` :param language: Stream language. You can specify up to 100 languages. |default| :code:`None` :param user_id: Returns streams broadcast by one or more specified user IDs. You can specify up to 100 IDs. |default| :code:`None` :param user_login: Returns streams broadcast by one or more specified user login names. You can specify up to 100 names. |default| :code:`None` :param stream_type: The type of stream to filter the list of streams by. Possible values are :code:`all` and :code:`live` |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if app authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if first is not in range 1 to 100 or one of the following fields have more than 100 entries: `user_id, game_id, language, user_login` """ if user_id is not None and len(user_id) > 100: raise ValueError('a maximum of 100 user_id entries are allowed') if user_login is not None and len(user_login) > 100: raise ValueError('a maximum of 100 user_login entries are allowed') if language is not None and len(language) > 100: raise ValueError('a maximum of 100 languages are allowed') if game_id is not None and len(game_id) > 100: raise ValueError('a maximum of 100 game_id entries are allowed') if first > 100 or first < 1: raise ValueError('first must be between 1 and 100') param = { 'after': after, 'before': before, 'first': first, 'game_id': game_id, 'language': language, 'user_id': user_id, 'user_login': user_login, 'type': stream_type } async for y in self._build_generator('GET', 'streams', param, AuthType.EITHER, [], Stream, split_lists=True): yield y async def get_stream_markers(self, user_id: str, video_id: str, after: Optional[str] = None, before: Optional[str] = None, first: int = 20) -> AsyncGenerator[GetStreamMarkerResponse, None]: """Gets a list of markers for either a specified user’s most recent stream or a specified VOD/video (stream), ordered by recency.\n\n Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.USER_READ_BROADCAST`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-stream-markers Only one of user_id and video_id must be specified. :param user_id: ID of the broadcaster from whose stream markers are returned. :param video_id: ID of the VOD/video whose stream markers are returned. :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param before: Cursor for backward pagination |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if first is not in range 1 to 100 or neither user_id nor video_id is provided :raises ~twitchAPI.type.TwitchResourceNotFound: if the user specified in user_id does not have videos """ if first > 100 or first < 1: raise ValueError('first must be between 1 and 100') if user_id is None and video_id is None: raise ValueError('you must specify either user_id and/or video_id') param = { 'user_id': user_id, 'video_id': video_id, 'after': after, 'before': before, 'first': first } async for y in self._build_generator('GET', 'streams/markers', param, AuthType.USER, [AuthScope.USER_READ_BROADCAST], GetStreamMarkerResponse): yield y async def get_broadcaster_subscriptions(self, broadcaster_id: str, user_ids: Optional[List[str]] = None, after: Optional[str] = None, first: Optional[int] = 20) -> BroadcasterSubscriptions: """Get all of a broadcaster’s subscriptions.\n\n Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_SUBSCRIPTIONS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-broadcaster-subscriptions :param broadcaster_id: User ID of the broadcaster. Must match the User ID in the Bearer token. :param user_ids: Unique identifier of account to get subscription status of. Maximum 100 entries |default| :code:`None` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if user_ids has more than 100 entries :raises ValueError: if first is not in range 1 to 100 """ if first < 1 or first > 100: raise ValueError('first must be in range 1 to 100') if user_ids is not None and len(user_ids) > 100: raise ValueError('user_ids can have a maximum of 100 entries') param = { 'broadcaster_id': broadcaster_id, 'user_id': user_ids, 'first': first, 'after': after } return await self._build_iter_result('GET', 'subscriptions', param, AuthType.USER, [AuthScope.CHANNEL_READ_SUBSCRIPTIONS], BroadcasterSubscriptions, split_lists=True) async def check_user_subscription(self, broadcaster_id: str, user_id: str) -> UserSubscription: """Checks if a specific user (user_id) is subscribed to a specific channel (broadcaster_id). Requires User or App Authorization with scope :const:`~twitchAPI.type.AuthScope.USER_READ_SUBSCRIPTIONS` For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#check-user-subscription :param broadcaster_id: User ID of an Affiliate or Partner broadcaster. :param user_id: User ID of a Twitch viewer. :raises ~twitchAPI.type.UnauthorizedException: if app or user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the app or user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchResourceNotFound: if user is not subscribed to the given broadcaster """ param = { 'broadcaster_id': broadcaster_id, 'user_id': user_id } return await self._build_result('GET', 'subscriptions/user', param, AuthType.EITHER, [AuthScope.USER_READ_SUBSCRIPTIONS], UserSubscription) async def get_channel_teams(self, broadcaster_id: str) -> List[ChannelTeam]: """Retrieves a list of Twitch Teams of which the specified channel/broadcaster is a member.\n\n Requires User or App authentication. For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference/#get-channel-teams :param broadcaster_id: User ID for a Twitch user. :raises ~twitchAPI.type.UnauthorizedException: if app or user authentication is not set or invalid :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchResourceNotFound: if the broadcaster was not found or is not member of a team """ return await self._build_result('GET', 'teams/channel', {'broadcaster_id': broadcaster_id}, AuthType.EITHER, [], List[ChannelTeam]) async def get_teams(self, team_id: Optional[str] = None, name: Optional[str] = None) -> ChannelTeam: """Gets information for a specific Twitch Team.\n\n Requires User or App authentication. One of the two optional query parameters must be specified. For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference/#get-teams :param team_id: Team ID |default| :code:`None` :param name: Team Name |default| :code:`None` :raises ~twitchAPI.type.UnauthorizedException: if app or user authentication is not set or invalid :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if neither team_id nor name are given or if both team_id and names are given. :raises ~twitchAPI.type.TwitchResourceNotFound: if the specified team was not found """ if team_id is None and name is None: raise ValueError('You need to specify one of the two optional parameter.') if team_id is not None and name is not None: raise ValueError('Only one optional parameter must be specified.') param = { 'id': team_id, 'name': name } return await self._build_result('GET', 'teams', param, AuthType.EITHER, [], ChannelTeam) async def get_users(self, user_ids: Optional[List[str]] = None, logins: Optional[List[str]] = None) -> AsyncGenerator[TwitchUser, None]: """Gets information about one or more specified Twitch users. Users are identified by optional user IDs and/or login name. If neither a user ID nor a login name is specified, the user is the one authenticated.\n\n Requires App authentication if either user_ids or logins is provided, otherwise requires a User authentication. If you have user Authentication and want to get your email info, you also need the authentication scope :const:`~twitchAPI.type.AuthScope.USER_READ_EMAIL`\n If you provide user_ids and/or logins, the maximum combined entries should not exceed 100. For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-users :param user_ids: User ID. Multiple user IDs can be specified. Limit: 100. |default| :code:`None` :param logins: User login name. Multiple login names can be specified. Limit: 100. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if more than 100 combined user_ids and logins where provided """ if (len(user_ids) if user_ids is not None else 0) + (len(logins) if logins is not None else 0) > 100: raise ValueError('the total number of entries in user_ids and logins can not be more than 100') url_params = { 'id': user_ids, 'login': logins } at = AuthType.USER if (user_ids is None or len(user_ids) == 0) and (logins is None or len(logins) == 0) else AuthType.EITHER async for f in self._build_generator('GET', 'users', url_params, at, [], TwitchUser, split_lists=True): yield f async def get_channel_followers(self, broadcaster_id: str, user_id: Optional[str] = None, first: Optional[int] = None, after: Optional[str] = None) -> ChannelFollowersResult: """ Gets a list of users that follow the specified broadcaster. You can also use this endpoint to see whether a specific user follows the broadcaster. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.MODERATOR_READ_FOLLOWERS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-channel-followers .. note:: This can also be used without the required scope or just with App Authentication, but the result will only include the total number of followers in these cases. :param broadcaster_id: The broadcaster’s ID. Returns the list of users that follow this broadcaster. :param user_id: A user’s ID. Use this parameter to see whether the user follows this broadcaster. If specified, the response contains this user if they follow the broadcaster. If not specified, the response contains all users that follow the broadcaster. |default|:code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if first is not in range 1 to 100 """ if first is not None and (first < 1 or first > 100): raise ValueError('first must be in range 1 to 100') param = { 'broadcaster_id': broadcaster_id, 'user_id': user_id, 'first': first, 'after': after } return await self._build_iter_result('GET', 'channels/followers', param, AuthType.EITHER, [], ChannelFollowersResult) async def get_followed_channels(self, user_id: str, broadcaster_id: Optional[str] = None, first: Optional[int] = None, after: Optional[str] = None) -> FollowedChannelsResult: """Gets a list of broadcasters that the specified user follows. You can also use this endpoint to see whether a user follows a specific broadcaster. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.USER_READ_FOLLOWS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-followed-channels :param user_id: A user’s ID. Returns the list of broadcasters that this user follows. This ID must match the user ID in the user OAuth token. :param broadcaster_id: A broadcaster’s ID. Use this parameter to see whether the user follows this broadcaster. If specified, the response contains this broadcaster if the user follows them. If not specified, the response contains all broadcasters that the user follows. |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if first is not in range 1 to 100 """ if first is not None and (first < 1 or first > 100): raise ValueError('first must be in range 1 to 100') param = { 'user_id': user_id, 'broadcaster_id': broadcaster_id, 'first': first, 'after': after } return await self._build_iter_result('GET', 'channels/followed', param, AuthType.USER, [AuthScope.USER_READ_FOLLOWS], FollowedChannelsResult) async def update_user(self, description: str) -> TwitchUser: """Updates the description of the Authenticated user.\n\n Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.USER_EDIT`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#update-user :param description: User’s account description :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems """ return await self._build_result('PUT', 'users', {'description': description}, AuthType.USER, [AuthScope.USER_EDIT], TwitchUser) async def get_user_extensions(self) -> List[UserExtension]: """Gets a list of all extensions (both active and inactive) for the authenticated user\n\n Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.USER_READ_BROADCAST`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-user-extensions :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems """ return await self._build_result('GET', 'users/extensions/list', {}, AuthType.USER, [AuthScope.USER_READ_BROADCAST], List[UserExtension]) async def get_user_active_extensions(self, user_id: Optional[str] = None) -> UserActiveExtensions: """Gets information about active extensions installed by a specified user, identified by a user ID or the authenticated user.\n\n Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.USER_READ_BROADCAST`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-user-active-extensions :param user_id: ID of the user whose installed extensions will be returned. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems """ return await self._build_result('GET', 'users/extensions', {'user_id': user_id}, AuthType.USER, [AuthScope.USER_READ_BROADCAST], UserActiveExtensions) async def update_user_extensions(self, data: UserActiveExtensions) -> UserActiveExtensions: """"Updates the activation state, extension ID, and/or version number of installed extensions for the authenticated user.\n\n Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.USER_EDIT_BROADCAST`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#update-user-extensions :param data: The user extension data to be written :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchResourceNotFound: if the extension specified in id and version was not found """ dat = {'data': data.to_dict(False)} return await self._build_result('PUT', 'users/extensions', {}, AuthType.USER, [AuthScope.USER_EDIT_BROADCAST], UserActiveExtensions, body_data=dat) async def get_videos(self, ids: Optional[List[str]] = None, user_id: Optional[str] = None, game_id: Optional[str] = None, after: Optional[str] = None, before: Optional[str] = None, first: Optional[int] = 20, language: Optional[str] = None, period: TimePeriod = TimePeriod.ALL, sort: SortMethod = SortMethod.TIME, video_type: VideoType = VideoType.ALL) -> AsyncGenerator[Video, None]: """Gets video information by video ID (one or more), user ID (one only), or game ID (one only).\n\n Requires App authentication.\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-videos :param ids: ID of the video being queried. Limit: 100. |default| :code:`None` :param user_id: ID of the user who owns the video. |default| :code:`None` :param game_id: ID of the game the video is of. |default| :code:`None` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param before: Cursor for backward pagination |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :param language: Language of the video being queried. |default| :code:`None` :param period: Period during which the video was created. |default| :code:`TimePeriod.ALL` :param sort: Sort order of the videos. |default| :code:`SortMethod.TIME` :param video_type: Type of video. |default| :code:`VideoType.ALL` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if app authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if first is not in range 1 to 100, ids has more than 100 entries or none of ids, user_id nor game_id is provided. :raises ~twitchAPI.type.TwitchResourceNotFound: if the game_id was not found or all IDs in video_id where not found """ if ids is None and user_id is None and game_id is None: raise ValueError('you must use either ids, user_id or game_id') if first < 1 or first > 100: raise ValueError('first must be between 1 and 100') if ids is not None and len(ids) > 100: raise ValueError('ids can only have a maximum of 100 entries') param = { 'id': ids, 'user_id': user_id, 'game_id': game_id, 'after': after, 'before': before, 'first': first, 'language': language, 'period': period.value, 'sort': sort.value, 'type': video_type.value } async for y in self._build_generator('GET', 'videos', param, AuthType.EITHER, [], Video, split_lists=True): yield y async def get_channel_information(self, broadcaster_id: Union[str, List[str]]) -> List[ChannelInformation]: """Gets channel information for users.\n\n Requires App or user authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-channel-information :param broadcaster_id: ID of the channel to be returned, can either be a string or a list of strings with up to 100 entries :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if app authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if broadcaster_id is a list and does not have between 1 and 100 entries """ if isinstance(broadcaster_id, list): if len(broadcaster_id) < 1 or len(broadcaster_id) > 100: raise ValueError('broadcaster_id has to have between 1 and 100 entries') return await self._build_result('GET', 'channels', {'broadcaster_id': broadcaster_id}, AuthType.EITHER, [], List[ChannelInformation], split_lists=True) async def modify_channel_information(self, broadcaster_id: str, game_id: Optional[str] = None, broadcaster_language: Optional[str] = None, title: Optional[str] = None, delay: Optional[int] = None, tags: Optional[List[str]] = None, content_classification_labels: Optional[List[str]] = None, is_branded_content: Optional[bool] = None) -> bool: """Modifies channel information for users.\n\n Requires User authentication with scope :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_BROADCAST`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#modify-channel-information :param broadcaster_id: ID of the channel to be updated :param game_id: The current game ID being played on the channel |default| :code:`None` :param broadcaster_language: The language of the channel |default| :code:`None` :param title: The title of the stream |default| :code:`None` :param delay: Stream delay in seconds. Trying to set this while not being a Twitch Partner will fail! |default| :code:`None` :param tags: A list of channel-defined tags to apply to the channel. To remove all tags from the channel, set tags to an empty array. |default|:code:`None` :param content_classification_labels: List of labels that should be set as the Channel’s CCLs. :param is_branded_content: Boolean flag indicating if the channel has branded content. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if none of the following fields are specified: `game_id, broadcaster_language, title` :raises ValueError: if title is a empty string :raises ValueError: if tags has more than 10 entries :raises ValueError: if requested a gaming CCL to channel or used Unallowed CCLs declared for underaged authorized user in a restricted country :raises ValueError: if the is_branded_content flag was set too frequently """ if game_id is None and broadcaster_language is None and title is None and tags is None: raise ValueError('You need to specify at least one of the optional parameter') if title is not None and len(title) == 0: raise ValueError('title cant be a empty string') if tags is not None and len(tags) > 10: raise ValueError('tags can only contain up to 10 items') body = {k: v for k, v in {'game_id': game_id, 'broadcaster_language': broadcaster_language, 'title': title, 'delay': delay, 'tags': tags, 'content_classification_labels': content_classification_labels, 'is_branded_content': is_branded_content}.items() if v is not None} error_handler = {403: ValueError('Either requested to add gaming CCL to channel or used Unallowed CCLs declared for underaged authorized ' 'user in a restricted country'), 409: ValueError('tried to set is_branded_content flag too frequently')} return await self._build_result('PATCH', 'channels', {'broadcaster_id': broadcaster_id}, AuthType.USER, [AuthScope.CHANNEL_MANAGE_BROADCAST], None, body_data=body, result_type=ResultType.STATUS_CODE, error_handler=error_handler) == 204 async def search_channels(self, query: str, first: Optional[int] = 20, after: Optional[str] = None, live_only: Optional[bool] = False) -> AsyncGenerator[SearchChannelResult, None]: """Returns a list of channels (users who have streamed within the past 6 months) that match the query via channel name or description either entirely or partially.\n\n Requires App authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#search-channels :param query: search query :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param live_only: Filter results for live streams only. |default| :code:`False` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if app authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if first is not in range 1 to 100 """ if first < 1 or first > 100: raise ValueError('first must be between 1 and 100') param = {'query': query, 'first': first, 'after': after, 'live_only': live_only} async for y in self._build_generator('GET', 'search/channels', param, AuthType.EITHER, [], SearchChannelResult): yield y async def search_categories(self, query: str, first: Optional[int] = 20, after: Optional[str] = None) -> AsyncGenerator[SearchCategoryResult, None]: """Returns a list of games or categories that match the query via name either entirely or partially.\n\n Requires App authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#search-categories :param query: search query :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if app authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if first is not in range 1 to 100 """ if first < 1 or first > 100: raise ValueError('first must be between 1 and 100') param = {'query': query, 'first': first, 'after': after} async for y in self._build_generator('GET', 'search/categories', param, AuthType.EITHER, [], SearchCategoryResult): yield y async def get_stream_key(self, broadcaster_id: str) -> str: """Gets the channel stream key for a user.\n\n Requires User authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_STREAM_KEY`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-stream-key :param broadcaster_id: User ID of the broadcaster :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems """ data = await self._build_result('GET', 'streams/key', {'broadcaster_id': broadcaster_id}, AuthType.USER, [AuthScope.CHANNEL_READ_STREAM_KEY], dict) return data['stream_key'] async def start_commercial(self, broadcaster_id: str, length: int) -> StartCommercialResult: """Starts a commercial on a specified channel.\n\n Requires User authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_EDIT_COMMERCIAL`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#start-commercial :param broadcaster_id: ID of the channel requesting a commercial :param length: Desired length of the commercial in seconds. , one of these: [30, 60, 90, 120, 150, 180] :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchResourceNotFound: if the broadcaster_id was not found :raises ValueError: if length is not one of these: :code:`30, 60, 90, 120, 150, 180` """ if length not in [30, 60, 90, 120, 150, 180]: raise ValueError('length needs to be one of these: [30, 60, 90, 120, 150, 180]') param = { 'broadcaster_id': broadcaster_id, 'length': length } return await self._build_result('POST', 'channels/commercial', param, AuthType.USER, [AuthScope.CHANNEL_EDIT_COMMERCIAL], StartCommercialResult) async def get_cheermotes(self, broadcaster_id: str) -> GetCheermotesResponse: """Retrieves the list of available Cheermotes, animated emotes to which viewers can assign Bits, to cheer in chat.\n\n Requires App authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-cheermotes :param broadcaster_id: ID for the broadcaster who might own specialized Cheermotes. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if app authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems """ return await self._build_result('GET', 'bits/cheermotes', {'broadcaster_id': broadcaster_id}, AuthType.EITHER, [], GetCheermotesResponse) async def get_hype_train_events(self, broadcaster_id: str, first: Optional[int] = 1, cursor: Optional[str] = None) -> AsyncGenerator[HypeTrainEvent, None]: """Gets the information of the most recent Hype Train of the given channel ID. When there is currently an active Hype Train, it returns information about that Hype Train. When there is currently no active Hype Train, it returns information about the most recent Hype Train. After 5 days, if no Hype Train has been active, the endpoint will return an empty response.\n\n Requires App or User authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_HYPE_TRAIN`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-hype-train-events :param broadcaster_id: User ID of the broadcaster. :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`1` :param cursor: Cursor for forward pagination |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if app authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user or app authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if first is not in range 1 to 100 """ if first < 1 or first > 100: raise ValueError('first must be between 1 and 100') param = {'broadcaster_id': broadcaster_id, 'first': first, 'cursor': cursor} async for y in self._build_generator('GET', 'hypetrain/events', param, AuthType.EITHER, [AuthScope.CHANNEL_READ_HYPE_TRAIN], HypeTrainEvent): yield y async def get_drops_entitlements(self, entitlement_id: Optional[str] = None, user_id: Optional[str] = None, game_id: Optional[str] = None, after: Optional[str] = None, first: Optional[int] = 20) -> AsyncGenerator[DropsEntitlement, None]: """Gets a list of entitlements for a given organization that have been granted to a game, user, or both. OAuth Token Client ID must have ownership of Game\n\n Requires App or User authentication\n See Twitch documentation for valid parameter combinations!\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-drops-entitlements :param entitlement_id: Unique Identifier of the entitlement |default| :code:`None` :param user_id: A Twitch User ID |default| :code:`None` :param game_id: A Twitch Game ID |default| :code:`None` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if app authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ValueError: if first is not in range 1 to 1000 """ if first < 1 or first > 1000: raise ValueError('first must be between 1 and 1000') can_use, auth_type, token, scope = self._get_used_either_auth([]) if auth_type == AuthType.USER: if user_id is not None: raise ValueError('cant use user_id when using User Authentication') param = { 'id': entitlement_id, 'user_id': user_id, 'game_id': game_id, 'after': after, 'first': first } async for y in self._build_generator('GET', 'entitlements/drops', param, AuthType.EITHER, [], DropsEntitlement): yield y async def create_custom_reward(self, broadcaster_id: str, title: str, cost: int, prompt: Optional[str] = None, is_enabled: Optional[bool] = True, background_color: Optional[str] = None, is_user_input_required: Optional[bool] = False, is_max_per_stream_enabled: Optional[bool] = False, max_per_stream: Optional[int] = None, is_max_per_user_per_stream_enabled: Optional[bool] = False, max_per_user_per_stream: Optional[int] = None, is_global_cooldown_enabled: Optional[bool] = False, global_cooldown_seconds: Optional[int] = None, should_redemptions_skip_request_queue: Optional[bool] = False) -> CustomReward: """Creates a Custom Reward on a channel. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_REDEMPTIONS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#create-custom-rewards :param broadcaster_id: ID of the broadcaster, must be same as user_id of auth token :param title: The title of the reward :param cost: The cost of the reward :param prompt: The prompt for the viewer when they are redeeming the reward |default| :code:`None` :param is_enabled: Is the reward currently enabled, if false the reward won’t show up to viewers. |default| :code:`True` :param background_color: Custom background color for the reward. Format: Hex with # prefix. Example: :code:`#00E5CB`. |default| :code:`None` :param is_user_input_required: Does the user need to enter information when redeeming the reward. |default| :code:`False` :param is_max_per_stream_enabled: Whether a maximum per stream is enabled. |default| :code:`False` :param max_per_stream: The maximum number per stream if enabled |default| :code:`None` :param is_max_per_user_per_stream_enabled: Whether a maximum per user per stream is enabled. |default| :code:`False` :param max_per_user_per_stream: The maximum number per user per stream if enabled |default| :code:`None` :param is_global_cooldown_enabled: Whether a cooldown is enabled. |default| :code:`False` :param global_cooldown_seconds: The cooldown in seconds if enabled |default| :code:`None` :param should_redemptions_skip_request_queue: Should redemptions be set to FULFILLED status immediately when redeemed and skip the request queue instead of the normal UNFULFILLED status. |default| :code:`False` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ValueError: if is_global_cooldown_enabled is True but global_cooldown_seconds is not specified :raises ValueError: if is_max_per_stream_enabled is True but max_per_stream is not specified :raises ValueError: if is_max_per_user_per_stream_enabled is True but max_per_user_per_stream is not specified :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.TwitchAPIException: if Channel Points are not available for the broadcaster """ if is_global_cooldown_enabled and global_cooldown_seconds is None: raise ValueError('please specify global_cooldown_seconds') if is_max_per_stream_enabled and max_per_stream is None: raise ValueError('please specify max_per_stream') if is_max_per_user_per_stream_enabled and max_per_user_per_stream is None: raise ValueError('please specify max_per_user_per_stream') param = {'broadcaster_id': broadcaster_id} body = {x: y for x, y in { 'title': title, 'prompt': prompt, 'cost': cost, 'is_enabled': is_enabled, 'background_color': background_color, 'is_user_input_required': is_user_input_required, 'is_max_per_stream_enabled': is_max_per_stream_enabled, 'max_per_stream': max_per_stream, 'is_max_per_user_per_stream_enabled': is_max_per_user_per_stream_enabled, 'max_per_user_per_stream': max_per_user_per_stream, 'is_global_cooldown_enabled': is_global_cooldown_enabled, 'global_cooldown_seconds': global_cooldown_seconds, 'should_redemptions_skip_request_queue': should_redemptions_skip_request_queue }.items() if y is not None} error_handler = {403: TwitchAPIException('Forbidden: Channel Points are not available for the broadcaster')} return await self._build_result('POST', 'channel_points/custom_rewards', param, AuthType.USER, [AuthScope.CHANNEL_MANAGE_REDEMPTIONS], CustomReward, body_data=body, error_handler=error_handler) async def delete_custom_reward(self, broadcaster_id: str, reward_id: str): """Deletes a Custom Reward on a channel. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_REDEMPTIONS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#delete-custom-rewards :param broadcaster_id: Provided broadcaster_id must match the user_id in the auth token :param reward_id: ID of the Custom Reward to delete, must match a Custom Reward on broadcaster_id’s channel. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.TwitchResourceNotFound: if the broadcaster has no custom reward with the given id :raises ~twitchAPI.type.TwitchResourceNotFound: if the custom reward specified in reward_id was not found """ await self._build_result('DELETE', 'channel_points/custom_rewards', {'broadcaster_id': broadcaster_id, 'id': reward_id}, AuthType.USER, [AuthScope.CHANNEL_MANAGE_REDEMPTIONS], None) async def get_custom_reward(self, broadcaster_id: str, reward_id: Optional[Union[str, List[str]]] = None, only_manageable_rewards: Optional[bool] = False) -> List[CustomReward]: """Returns a list of Custom Reward objects for the Custom Rewards on a channel. Developers only have access to update and delete rewards that the same/calling client_id created. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_REDEMPTIONS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-custom-reward :param broadcaster_id: Provided broadcaster_id must match the user_id in the auth token :param reward_id: When used, this parameter filters the results and only returns reward objects for the Custom Rewards with matching ID. Maximum: 50 |default| :code:`None` :param only_manageable_rewards: When set to true, only returns custom rewards that the calling client_id can manage. |default| :code:`False` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if app authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user or app authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchResourceNotFound: if all custom rewards specified in reward_id where not found :raises ValueError: if if reward_id is longer than 50 entries """ if reward_id is not None and isinstance(reward_id, list) and len(reward_id) > 50: raise ValueError('reward_id can not contain more than 50 entries') param = { 'broadcaster_id': broadcaster_id, 'id': reward_id, 'only_manageable_rewards': only_manageable_rewards } return await self._build_result('GET', 'channel_points/custom_rewards', param, AuthType.USER, [AuthScope.CHANNEL_READ_REDEMPTIONS], List[CustomReward], split_lists=True) async def get_custom_reward_redemption(self, broadcaster_id: str, reward_id: str, redemption_id: Optional[List[str]] = None, status: Optional[CustomRewardRedemptionStatus] = None, sort: Optional[SortOrder] = SortOrder.OLDEST, after: Optional[str] = None, first: Optional[int] = 20) -> AsyncGenerator[CustomRewardRedemption, None]: """Returns Custom Reward Redemption objects for a Custom Reward on a channel that was created by the same client_id. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_REDEMPTIONS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-custom-reward-redemption :param broadcaster_id: Provided broadcaster_id must match the user_id in the auth token :param reward_id: When ID is not provided, this parameter returns paginated Custom Reward Redemption objects for redemptions of the Custom Reward with ID reward_id :param redemption_id: When used, this param filters the results and only returns Custom Reward Redemption objects for the redemptions with matching ID. Maximum: 50 ids |default| :code:`None` :param status: When id is not provided, this param is required and filters the paginated Custom Reward Redemption objects for redemptions with the matching status. |default| :code:`None` :param sort: Sort order of redemptions returned when getting the paginated Custom Reward Redemption objects for a reward. |default| :code:`SortOrder.OLDEST` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 50 |default| :code:`20` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if app authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user or app authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchResourceNotFound: if all redemptions specified in redemption_id where not found :raises ValueError: if id has more than 50 entries :raises ValueError: if first is not in range 1 to 50 :raises ValueError: if status and id are both :code:`None` """ if first is not None and (first < 1 or first > 50): raise ValueError('first must be in range 1 to 50') if redemption_id is not None and len(redemption_id) > 50: raise ValueError('id can not have more than 50 entries') if status is None and redemption_id is None: raise ValueError('you have to set at least one of status or id') param = { 'broadcaster_id': broadcaster_id, 'reward_id': reward_id, 'id': redemption_id, 'status': status, 'sort': sort, 'after': after, 'first': first } error_handler = { 403: TwitchAPIException('The ID in the Client-Id header must match the client ID used to create the custom reward or ' 'the broadcaster is not a partner or affiliate') } async for y in self._build_generator('GET', 'channel_points/custom_rewards/redemptions', param, AuthType.USER, [AuthScope.CHANNEL_READ_REDEMPTIONS], CustomRewardRedemption, split_lists=True, error_handler=error_handler): yield y async def update_custom_reward(self, broadcaster_id: str, reward_id: str, title: Optional[str] = None, prompt: Optional[str] = None, cost: Optional[int] = None, is_enabled: Optional[bool] = True, background_color: Optional[str] = None, is_user_input_required: Optional[bool] = False, is_max_per_stream_enabled: Optional[bool] = False, max_per_stream: Optional[int] = None, is_max_per_user_per_stream_enabled: Optional[bool] = False, max_per_user_per_stream: Optional[int] = None, is_global_cooldown_enabled: Optional[bool] = False, global_cooldown_seconds: Optional[int] = None, should_redemptions_skip_request_queue: Optional[bool] = False) -> CustomReward: """Updates a Custom Reward created on a channel. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_REDEMPTIONS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#update-custom-reward :param broadcaster_id: ID of the broadcaster, must be same as user_id of auth token :param reward_id: ID of the reward that you want to update :param title: The title of the reward |default| :code:`None` :param prompt: The prompt for the viewer when they are redeeming the reward |default| :code:`None` :param cost: The cost of the reward |default| :code:`None` :param is_enabled: Is the reward currently enabled, if false the reward won’t show up to viewers. |default| :code:`true` :param background_color: Custom background color for the reward. |default| :code:`None` Format: Hex with # prefix. Example: :code:`#00E5CB`. :param is_user_input_required: Does the user need to enter information when redeeming the reward. |default| :code:`false` :param is_max_per_stream_enabled: Whether a maximum per stream is enabled. |default| :code:`False` :param max_per_stream: The maximum number per stream if enabled |default| :code:`None` :param is_max_per_user_per_stream_enabled: Whether a maximum per user per stream is enabled. |default| :code:`False` :param max_per_user_per_stream: The maximum number per user per stream if enabled |default| :code:`None` :param is_global_cooldown_enabled: Whether a cooldown is enabled. |default| :code:`false` :param global_cooldown_seconds: The cooldown in seconds if enabled |default| :code:`None` :param should_redemptions_skip_request_queue: Should redemptions be set to FULFILLED status immediately when redeemed and skip the request queue instead of the normal UNFULFILLED status. |default| :code:`False` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ValueError: if is_global_cooldown_enabled is True but global_cooldown_seconds is not specified :raises ValueError: if is_max_per_stream_enabled is True but max_per_stream is not specified :raises ValueError: if is_max_per_user_per_stream_enabled is True but max_per_user_per_stream is not specified :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.TwitchAPIException: if Channel Points are not available for the broadcaster or the custom reward belongs to a different broadcaster :raises ~twitchAPI.type.TwitchResourceNotFound: if the custom reward specified in reward_id was not found :raises ValueError: if the given reward_id does not match a custom reward by the given broadcaster """ if is_global_cooldown_enabled and global_cooldown_seconds is None: raise ValueError('please specify global_cooldown_seconds') elif not is_global_cooldown_enabled and global_cooldown_seconds is None: is_global_cooldown_enabled = None if is_max_per_stream_enabled and max_per_stream is None: raise ValueError('please specify max_per_stream') elif not is_max_per_stream_enabled and max_per_stream is None: is_max_per_stream_enabled = None if is_max_per_user_per_stream_enabled and max_per_user_per_stream is None: raise ValueError('please specify max_per_user_per_stream') elif not is_max_per_user_per_stream_enabled and max_per_user_per_stream is None: is_max_per_user_per_stream_enabled = None param = { 'broadcaster_id': broadcaster_id, 'id': reward_id } body = {x: y for x, y in { 'title': title, 'prompt': prompt, 'cost': cost, 'is_enabled': is_enabled, 'background_color': background_color, 'is_user_input_required': is_user_input_required, 'is_max_per_stream_enabled': is_max_per_stream_enabled, 'max_per_stream': max_per_stream, 'is_max_per_user_per_stream_enabled': is_max_per_user_per_stream_enabled, 'max_per_user_per_stream': max_per_user_per_stream, 'is_global_cooldown_enabled': is_global_cooldown_enabled, 'global_cooldown_seconds': global_cooldown_seconds, 'should_redemptions_skip_request_queue': should_redemptions_skip_request_queue }.items() if y is not None} error_handler = { 403: TwitchAPIException('This custom reward was created by a different broadcaster or channel points are' 'not available for the broadcaster') } return await self._build_result('PATCH', 'channel_points/custom_rewards', param, AuthType.USER, [AuthScope.CHANNEL_MANAGE_REDEMPTIONS], CustomReward, body_data=body, error_handler=error_handler) async def update_redemption_status(self, broadcaster_id: str, reward_id: str, redemption_ids: Union[List[str], str], status: CustomRewardRedemptionStatus) -> CustomRewardRedemption: """Updates the status of Custom Reward Redemption objects on a channel that are in the :code:`UNFULFILLED` status. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_REDEMPTIONS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#update-redemption-status :param broadcaster_id: Provided broadcaster_id must match the user_id in the auth token. :param reward_id: ID of the Custom Reward the redemptions to be updated are for. :param redemption_ids: IDs of the Custom Reward Redemption to update, must match a Custom Reward Redemption on broadcaster_id’s channel Max: 50 :param status: The new status to set redemptions to. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.TwitchAPIException: if Channel Points are not available for the broadcaster or the custom reward belongs to a different broadcaster :raises ValueError: if redemption_ids is longer than 50 entries :raises ~twitchAPI.type.TwitchResourceNotFound: if no custom reward redemptions with status UNFULFILLED where found for the given ids :raises ~twitchAPI.type.TwitchAPIException: if Channel Points are not available for the broadcaster or the custom reward belongs to a different broadcaster """ if isinstance(redemption_ids, list) and len(redemption_ids) > 50: raise ValueError('redemption_ids cant have more than 50 entries') param = { 'id': redemption_ids, 'broadcaster_id': broadcaster_id, 'reward_id': reward_id } body = {'status': status.value} error_handler = { 403: TwitchAPIException('This custom reward was created by a different broadcaster or channel points are ' 'not available for the broadcaster') } return await self._build_result('PATCH', 'channel_points/custom_rewards/redemptions', param, AuthType.USER, [AuthScope.CHANNEL_MANAGE_REDEMPTIONS], CustomRewardRedemption, body_data=body, split_lists=True, error_handler=error_handler) async def get_channel_editors(self, broadcaster_id: str) -> List[ChannelEditor]: """Gets a list of users who have editor permissions for a specific channel. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_EDITORS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-channel-editors :param broadcaster_id: Broadcaster’s user ID associated with the channel :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ return await self._build_result('GET', 'channel/editors', {'broadcaster_id': broadcaster_id}, AuthType.USER, [AuthScope.CHANNEL_READ_EDITORS], List[ChannelEditor]) async def delete_videos(self, video_ids: List[str]) -> List[str]: """Deletes one or more videos. Videos are past broadcasts, Highlights, or uploads. Returns False if the User was not Authorized to delete at least one of the given videos. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_VIDEOS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#delete-videos :param video_ids: ids of the videos, Limit: 5 ids :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if video_ids contains more than 5 entries or is a empty list """ if video_ids is None or len(video_ids) == 0 or len(video_ids) > 5: raise ValueError('video_ids must contain between 1 and 5 entries') return await self._build_result('DELETE', 'videos', {'id': video_ids}, AuthType.USER, [AuthScope.CHANNEL_MANAGE_VIDEOS], List[str], split_lists=True) async def get_user_block_list(self, broadcaster_id: str, first: Optional[int] = 20, after: Optional[str] = None) -> AsyncGenerator[BlockListEntry, None]: """Gets a specified user’s block list. The list is sorted by when the block occurred in descending order (i.e. most recent block first). Requires User Authentication with :const:`~twitchAPI.type.AuthScope.USER_READ_BLOCKED_USERS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-user-block-list :param broadcaster_id: User ID for a twitch user :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`20` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if first is not in range 1 to 100 """ if first < 1 or first > 100: raise ValueError('first must be in range 1 to 100') param = { 'broadcaster_id': broadcaster_id, 'first': first, 'after': after} async for y in self._build_generator('GET', 'users/blocks', param, AuthType.USER, [AuthScope.USER_READ_BLOCKED_USERS], BlockListEntry): yield y async def block_user(self, target_user_id: str, source_context: Optional[BlockSourceContext] = None, reason: Optional[BlockReason] = None) -> bool: """Blocks the specified user on behalf of the authenticated user. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.USER_MANAGE_BLOCKED_USERS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#block-user :param target_user_id: User ID of the user to be blocked. :param source_context: Source context for blocking the user. |default| :code:`None` :param reason: Reason for blocking the user. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ param = { 'target_user_id': target_user_id, 'source_context': enum_value_or_none(source_context), 'reason': enum_value_or_none(reason)} return await self._build_result('PUT', 'users/blocks', param, AuthType.USER, [AuthScope.USER_MANAGE_BLOCKED_USERS], None, result_type=ResultType.STATUS_CODE) == 204 async def unblock_user(self, target_user_id: str) -> bool: """Unblocks the specified user on behalf of the authenticated user. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.USER_MANAGE_BLOCKED_USERS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#unblock-user :param target_user_id: User ID of the user to be unblocked. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ return await self._build_result('DELETE', 'users/blocks', {'target_user_id': target_user_id}, AuthType.USER, [AuthScope.USER_MANAGE_BLOCKED_USERS], None, result_type=ResultType.STATUS_CODE) == 204 async def get_followed_streams(self, user_id: str, after: Optional[str] = None, first: Optional[int] = 100) -> AsyncGenerator[Stream, None]: """Gets information about active streams belonging to channels that the authenticated user follows. Streams are returned sorted by number of current viewers, in descending order. Across multiple pages of results, there may be duplicate or missing streams, as viewers join and leave streams. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.USER_READ_FOLLOWS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-followed-streams :param user_id: Results will only include active streams from the channels that this Twitch user follows. user_id must match the User ID in the bearer token. :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default| :code:`100` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if first is not in range 1 to 100 """ if first < 1 or first > 100: raise ValueError('first must be in range 1 to 100') param = { 'user_id': user_id, 'after': after, 'first': first } async for y in self._build_generator('GET', 'streams/followed', param, AuthType.USER, [AuthScope.USER_READ_FOLLOWS], Stream): yield y async def get_polls(self, broadcaster_id: str, poll_id: Union[None, str, List[str]] = None, after: Optional[str] = None, first: Optional[int] = 20) -> AsyncGenerator[Poll, None]: """Get information about all polls or specific polls for a Twitch channel. Poll information is available for 90 days. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_POLLS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-polls :param broadcaster_id: The broadcaster running polls. Provided broadcaster_id must match the user_id in the user OAuth token. :param poll_id: ID(s) of a poll. You can specify up to 20 poll ids |default| :code:`None` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 20 |default| :code:`20` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.TwitchResourceNotFound: if none of the IDs in poll_id where found :raises ValueError: if first is not in range 1 to 20 :raises ValueError: if poll_id has more than 20 entries """ if poll_id is not None and isinstance(poll_id, List) and len(poll_id) > 20: raise ValueError('You may only specify up to 20 poll IDs') if first is not None and (first < 1 or first > 20): raise ValueError('first must be in range 1 to 20') param = { 'broadcaster_id': broadcaster_id, 'id': poll_id, 'after': after, 'first': first } async for y in self._build_generator('GET', 'polls', param, AuthType.USER, [AuthScope.CHANNEL_READ_POLLS], Poll, split_lists=True): yield y async def create_poll(self, broadcaster_id: str, title: str, choices: List[str], duration: int, channel_points_voting_enabled: bool = False, channel_points_per_vote: Optional[int] = None) -> Poll: """Create a poll for a specific Twitch channel. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_POLLS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#create-poll :param broadcaster_id: The broadcaster running the poll :param title: Question displayed for the poll :param choices: List of poll choices. :param duration: Total duration for the poll (in seconds). Minimum 15, Maximum 1800 :param channel_points_voting_enabled: Indicates if Channel Points can be used for voting. |default| :code:`False` :param channel_points_per_vote: Number of Channel Points required to vote once with Channel Points. Minimum: 0. Maximum: 1000000. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if duration is not in range 15 to 1800 :raises ValueError: if channel_points_per_vote is not in range 0 to 1000000 """ if duration < 15 or duration > 1800: raise ValueError('duration must be between 15 and 1800') if channel_points_per_vote is not None: if channel_points_per_vote < 0 or channel_points_per_vote > 1_000_000: raise ValueError('channel_points_per_vote must be in range 0 to 1000000') if len(choices) < 0 or len(choices) > 5: raise ValueError('require between 2 and 5 choices') body = {k: v for k, v in { 'broadcaster_id': broadcaster_id, 'title': title, 'choices': [{'title': x} for x in choices], 'duration': duration, 'channel_points_voting_enabled': channel_points_voting_enabled, 'channel_points_per_vote': channel_points_per_vote }.items() if v is not None} return await self._build_result('POST', 'polls', {}, AuthType.USER, [AuthScope.CHANNEL_MANAGE_POLLS], Poll, body_data=body) async def end_poll(self, broadcaster_id: str, poll_id: str, status: PollStatus) -> Poll: """End a poll that is currently active. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_POLLS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#end-poll :param broadcaster_id: id of the broadcaster running the poll :param poll_id: id of the poll :param status: The poll status to be set :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if status is not TERMINATED or ARCHIVED """ if status not in (PollStatus.TERMINATED, PollStatus.ARCHIVED): raise ValueError('status must be either TERMINATED or ARCHIVED') body = { 'broadcaster_id': broadcaster_id, 'id': poll_id, 'status': status.value } return await self._build_result('PATCH', 'polls', {}, AuthType.USER, [AuthScope.CHANNEL_MANAGE_POLLS], Poll, body_data=body) async def get_predictions(self, broadcaster_id: str, prediction_ids: Optional[List[str]] = None, after: Optional[str] = None, first: Optional[int] = 20) -> AsyncGenerator[Prediction, None]: """Get information about all Channel Points Predictions or specific Channel Points Predictions for a Twitch channel. Results are ordered by most recent, so it can be assumed that the currently active or locked Prediction will be the first item. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_PREDICTIONS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-predictions :param broadcaster_id: The broadcaster running the prediction :param prediction_ids: List of prediction ids. |default| :code:`None` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 20 |default| :code:`20` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if first is not in range 1 to 20 :raises ValueError: if prediction_ids contains more than 100 entries """ if first is not None and (first < 1 or first > 20): raise ValueError('first must be in range 1 to 20') if prediction_ids is not None and len(prediction_ids) > 100: raise ValueError('maximum of 100 prediction ids allowed') param = { 'broadcaster_id': broadcaster_id, 'id': prediction_ids, 'after': after, 'first': first } async for y in self._build_generator('GET', 'predictions', param, AuthType.USER, [AuthScope.CHANNEL_READ_PREDICTIONS], Prediction, split_lists=True): yield y async def create_prediction(self, broadcaster_id: str, title: str, outcomes: List[str], prediction_window: int) -> Prediction: """Create a Channel Points Prediction for a specific Twitch channel. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_PREDICTIONS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#create-prediction :param broadcaster_id: The broadcaster running the prediction :param title: Title of the Prediction :param outcomes: List of possible Outcomes, must contain between 2 and 10 entries :param prediction_window: Total duration for the Prediction (in seconds). Minimum 1, Maximum 1800 :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if prediction_window is not in range 1 to 1800 :raises ValueError: if outcomes does not contain exactly 2 entries """ if prediction_window < 1 or prediction_window > 1800: raise ValueError('prediction_window must be in range 1 to 1800') if len(outcomes) < 2 or len(outcomes) > 10: raise ValueError('outcomes must have between 2 entries and 10 entries') body = { 'broadcaster_id': broadcaster_id, 'title': title, 'outcomes': [{'title': x} for x in outcomes], 'prediction_window': prediction_window } return await self._build_result('POST', 'predictions', {}, AuthType.USER, [AuthScope.CHANNEL_MANAGE_PREDICTIONS], Prediction, body_data=body) async def end_prediction(self, broadcaster_id: str, prediction_id: str, status: PredictionStatus, winning_outcome_id: Optional[str] = None) -> Prediction: """Lock, resolve, or cancel a Channel Points Prediction. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_PREDICTIONS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#end-prediction :param broadcaster_id: ID of the broadcaster :param prediction_id: ID of the Prediction :param status: The Prediction status to be set. :param winning_outcome_id: ID of the winning outcome for the Prediction. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if winning_outcome_id is None and status is RESOLVED :raises ValueError: if status is not one of RESOLVED, CANCELED or LOCKED :raises ~twitchAPI.type.TwitchResourceNotFound: if prediction_id or winning_outcome_id where not found """ if status not in (PredictionStatus.RESOLVED, PredictionStatus.CANCELED, PredictionStatus.LOCKED): raise ValueError('status has to be one of RESOLVED, CANCELED or LOCKED') if status == PredictionStatus.RESOLVED and winning_outcome_id is None: raise ValueError('need to specify winning_outcome_id for status RESOLVED') body = { 'broadcaster_id': broadcaster_id, 'id': prediction_id, 'status': status.value } if winning_outcome_id is not None: body['winning_outcome_id'] = winning_outcome_id return await self._build_result('PATCH', 'predictions', {}, AuthType.USER, [AuthScope.CHANNEL_MANAGE_PREDICTIONS], Prediction, body_data=body) async def start_raid(self, from_broadcaster_id: str, to_broadcaster_id: str) -> RaidStartResult: """ Raid another channel by sending the broadcaster’s viewers to the targeted channel. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_RAIDS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#start-a-raid :param from_broadcaster_id: The ID of the broadcaster that's sending the raiding party. :param to_broadcaster_id: The ID of the broadcaster to raid. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.TwitchResourceNotFound: if the target channel was not found """ param = { 'from_broadcaster_id': from_broadcaster_id, 'to_broadcaster_id': to_broadcaster_id } return await self._build_result('POST', 'raids', param, AuthType.USER, [AuthScope.CHANNEL_MANAGE_RAIDS], RaidStartResult) async def cancel_raid(self, broadcaster_id: str): """Cancel a pending raid. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_RAIDS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#cancel-a-raid :param broadcaster_id: The ID of the broadcaster that sent the raiding party. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.TwitchResourceNotFound: if the broadcaster does not have a pending raid to cancel """ await self._build_result('DELETE', 'raids', {'broadcaster_id': broadcaster_id}, AuthType.USER, [AuthScope.CHANNEL_MANAGE_RAIDS], None) async def manage_held_automod_message(self, user_id: str, msg_id: str, action: AutoModAction): """Allow or deny a message that was held for review by AutoMod. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_AUTOMOD`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#manage-held-automod-messages :param user_id: The moderator who is approving or rejecting the held message. :param msg_id: ID of the targeted message :param action: The action to take for the message. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.TwitchResourceNotFound: if the message specified in msg_id was not found """ body = { 'user_id': user_id, 'msg_id': msg_id, 'action': action.value } await self._build_result('POST', 'moderation/automod/message', {}, AuthType.USER, [AuthScope.MODERATOR_MANAGE_AUTOMOD], None, body_data=body) async def get_chat_badges(self, broadcaster_id: str) -> List[ChatBadge]: """Gets a list of custom chat badges that can be used in chat for the specified channel. Requires User or App Authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-channel-chat-badges :param broadcaster_id: The ID of the broadcaster whose chat badges you want to get. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ return await self._build_result('GET', 'chat/badges', {'broadcaster_id': broadcaster_id}, AuthType.EITHER, [], List[ChatBadge]) async def get_global_chat_badges(self) -> List[ChatBadge]: """Gets a list of chat badges that can be used in chat for any channel. Requires User or App Authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-global-chat-badges :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ return await self._build_result('GET', 'chat/badges/global', {}, AuthType.EITHER, [], List[ChatBadge]) async def get_channel_emotes(self, broadcaster_id: str) -> GetEmotesResponse: """Gets all emotes that the specified Twitch channel created. Requires User or App Authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-channel-emotes :param broadcaster_id: ID of the broadcaster :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ return await self._build_result('GET', 'chat/emotes', {'broadcaster_id': broadcaster_id}, AuthType.EITHER, [], GetEmotesResponse, get_from_data=False) async def get_global_emotes(self) -> GetEmotesResponse: """Gets all global emotes. Requires User or App Authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-global-emotes :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ return await self._build_result('GET', 'chat/emotes/global', {}, AuthType.EITHER, [], GetEmotesResponse, get_from_data=False) async def get_emote_sets(self, emote_set_id: List[str]) -> GetEmotesResponse: """Gets emotes for one or more specified emote sets. Requires User or App Authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-emote-sets :param emote_set_id: A list of IDs that identify the emote sets. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ if len(emote_set_id) == 0 or len(emote_set_id) > 25: raise ValueError('you need to specify between 1 and 25 emote_set_ids') return await self._build_result('GET', 'chat/emotes/set', {'emote_set_id': emote_set_id}, AuthType.EITHER, [], GetEmotesResponse, split_lists=True) async def delete_eventsub_subscription(self, subscription_id: str): """Deletes an EventSub subscription. Requires App Authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#delete-eventsub-subscription :param subscription_id: The ID of the subscription :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.TwitchResourceNotFound: if the subscription was not found """ await self._build_result('DELETE', 'eventsub/subscriptions', {'id': subscription_id}, AuthType.APP, [], None) async def get_eventsub_subscriptions(self, status: Optional[str] = None, sub_type: Optional[str] = None, user_id: Optional[str] = None, after: Optional[str] = None) -> GetEventSubSubscriptionResult: """Gets a list of your EventSub subscriptions. The list is paginated and ordered by the oldest subscription first. Requires App Authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-eventsub-subscriptions :param status: Filter subscriptions by its status. |default| :code:`None` :param sub_type: Filter subscriptions by subscription type. |default| :code:`None` :param user_id: Filter subscriptions by user ID. |default| :code:`None` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ param = { 'status': status, 'type': sub_type, 'user_id': user_id, 'after': after } return await self._build_iter_result('GET', 'eventsub/subscriptions', param, AuthType.APP, [], GetEventSubSubscriptionResult) async def get_channel_stream_schedule(self, broadcaster_id: str, stream_segment_ids: Optional[List[str]] = None, start_time: Optional[datetime] = None, utc_offset: Optional[str] = None, first: Optional[int] = 20, after: Optional[str] = None) -> ChannelStreamSchedule: """Gets all scheduled broadcasts or specific scheduled broadcasts from a channel’s stream schedule. Requires App or User Authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-channel-stream-schedule :param broadcaster_id: user id of the broadcaster :param stream_segment_ids: optional list of stream segment ids. Maximum 100 entries. |default| :code:`None` :param start_time: optional timestamp to start returning stream segments from. |default| :code:`None` :param utc_offset: A timezone offset to be used. |default| :code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 25 |default| :code:`20` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.TwitchResourceNotFound: if the broadcaster has not created a streaming schedule :raises ValueError: if stream_segment_ids has more than 100 entries :raises ValueError: if first is not in range 1 to 25 """ if stream_segment_ids is not None and len(stream_segment_ids) > 100: raise ValueError('stream_segment_ids can only have 100 entries') if first is not None and (first > 25 or first < 1): raise ValueError('first has to be in range 1 to 25') param = { 'broadcaster_id': broadcaster_id, 'id': stream_segment_ids, 'start_time': datetime_to_str(start_time), 'utc_offset': utc_offset, 'first': first, 'after': after } return await self._build_iter_result('GET', 'schedule', param, AuthType.EITHER, [], ChannelStreamSchedule, split_lists=True, in_data=True, iter_field='segments') async def get_channel_icalendar(self, broadcaster_id: str) -> str: """Gets all scheduled broadcasts from a channel’s stream schedule as an iCalendar. Does not require Authorization\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-channel-icalendar :param broadcaster_id: id of the broadcaster :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ return await self._build_result('GET', 'schedule/icalendar', {'broadcaster_id': broadcaster_id}, AuthType.NONE, [], None, result_type=ResultType.TEXT) async def update_channel_stream_schedule(self, broadcaster_id: str, is_vacation_enabled: Optional[bool] = None, vacation_start_time: Optional[datetime] = None, vacation_end_time: Optional[datetime] = None, timezone: Optional[str] = None): """Update the settings for a channel’s stream schedule. This can be used for setting vacation details. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_SCHEDULE`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#update-channel-stream-schedule :param broadcaster_id: id of the broadcaster :param is_vacation_enabled: indicates if Vacation Mode is enabled. |default| :code:`None` :param vacation_start_time: Start time for vacation |default| :code:`None` :param vacation_end_time: End time for vacation specified |default| :code:`None` :param timezone: The timezone for when the vacation is being scheduled using the IANA time zone database format. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.TwitchResourceNotFound: if the broadcasters schedule was not found """ param = { 'broadcaster_id': broadcaster_id, 'is_vacation_enabled': is_vacation_enabled, 'vacation_start_time': datetime_to_str(vacation_start_time), 'vacation_end_time': datetime_to_str(vacation_end_time), 'timezone': timezone } await self._build_result('PATCH', 'schedule/settings', param, AuthType.USER, [AuthScope.CHANNEL_MANAGE_SCHEDULE], None) async def create_channel_stream_schedule_segment(self, broadcaster_id: str, start_time: datetime, timezone: str, is_recurring: bool, duration: Optional[str] = None, category_id: Optional[str] = None, title: Optional[str] = None) -> ChannelStreamSchedule: """Create a single scheduled broadcast or a recurring scheduled broadcast for a channel’s stream schedule. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_SCHEDULE`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#create-channel-stream-schedule-segment :param broadcaster_id: id of the broadcaster :param start_time: Start time for the scheduled broadcast :param timezone: The timezone of the application creating the scheduled broadcast using the IANA time zone database format. :param is_recurring: Indicates if the scheduled broadcast is recurring weekly. :param duration: Duration of the scheduled broadcast in minutes from the start_time. |default| :code:`240` :param category_id: Game/Category ID for the scheduled broadcast. |default| :code:`None` :param title: Title for the scheduled broadcast. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ param = {'broadcaster_id': broadcaster_id} body = remove_none_values({ 'start_time': datetime_to_str(start_time), 'timezone': timezone, 'is_recurring': is_recurring, 'duration': duration, 'category_id': category_id, 'title': title }) return await self._build_iter_result('POST', 'schedule/segment', param, AuthType.USER, [AuthScope.CHANNEL_MANAGE_SCHEDULE], ChannelStreamSchedule, body_data=body, in_data=True, iter_field='segments') async def update_channel_stream_schedule_segment(self, broadcaster_id: str, stream_segment_id: str, start_time: Optional[datetime] = None, duration: Optional[str] = None, category_id: Optional[str] = None, title: Optional[str] = None, is_canceled: Optional[bool] = None, timezone: Optional[str] = None) -> ChannelStreamSchedule: """Update a single scheduled broadcast or a recurring scheduled broadcast for a channel’s stream schedule. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_SCHEDULE`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#update-channel-stream-schedule-segment :param broadcaster_id: id of the broadcaster :param stream_segment_id: The ID of the streaming segment to update. :param start_time: Start time for the scheduled broadcast |default| :code:`None` :param duration: Duration of the scheduled broadcast in minutes from the start_time. |default| :code:`240` :param category_id: Game/Category ID for the scheduled broadcast. |default| :code:`None` :param title: Title for the scheduled broadcast. |default| :code:`None` :param is_canceled: Indicated if the scheduled broadcast is canceled. |default| :code:`None` :param timezone: The timezone of the application creating the scheduled broadcast using the IANA time zone database format. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.TwitchResourceNotFound: if the specified broadcast segment was not found """ param = { 'broadcaster_id': broadcaster_id, 'id': stream_segment_id } body = remove_none_values({ 'start_time': datetime_to_str(start_time), 'duration': duration, 'category_id': category_id, 'title': title, 'is_canceled': is_canceled, 'timezone': timezone }) return await self._build_iter_result('PATCH', 'schedule/segment', param, AuthType.USER, [AuthScope.CHANNEL_MANAGE_SCHEDULE], ChannelStreamSchedule, body_data=body, in_data=True, iter_field='segments') async def delete_channel_stream_schedule_segment(self, broadcaster_id: str, stream_segment_id: str): """Delete a single scheduled broadcast or a recurring scheduled broadcast for a channel’s stream schedule. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_SCHEDULE`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#delete-channel-stream-schedule-segment :param broadcaster_id: id of the broadcaster :param stream_segment_id: The ID of the streaming segment to delete. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ param = { 'broadcaster_id': broadcaster_id, 'id': stream_segment_id } await self._build_result('DELETE', 'schedule/segment', param, AuthType.USER, [AuthScope.CHANNEL_MANAGE_SCHEDULE], None) async def update_drops_entitlements(self, entitlement_ids: List[str], fulfillment_status: EntitlementFulfillmentStatus) -> List[DropsEntitlement]: """Updates the fulfillment status on a set of Drops entitlements, specified by their entitlement IDs. Requires User or App Authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#update-drops-entitlements :param entitlement_ids: An array of unique identifiers of the entitlements to update. :param fulfillment_status: A fulfillment status. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if entitlement_ids has more than 100 entries """ if len(entitlement_ids) > 100: raise ValueError('entitlement_ids can only have a maximum of 100 entries') body = remove_none_values({ 'entitlement_ids': entitlement_ids, 'fulfillment_status': fulfillment_status.value }) return await self._build_result('PATCH', 'entitlements/drops', {}, AuthType.EITHER, [], List[DropsEntitlement], body_data=body) async def send_whisper(self, from_user_id: str, to_user_id: str, message: str): """Sends a whisper message to the specified user. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.USER_MANAGE_WHISPERS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#send-whisper :param from_user_id: The ID of the user sending the whisper. :param to_user_id: The ID of the user to receive the whisper. :param message: The whisper message to send. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.TwitchResourceNotFound: if the user specified in to_user_id was not found :raises ValueError: if message is empty """ if len(message) == 0: raise ValueError('message can\'t be empty') param = { 'from_user_id': from_user_id, 'to_user_id': to_user_id } body = {'message': message} await self._build_result('POST', 'whispers', param, AuthType.USER, [AuthScope.USER_MANAGE_WHISPERS], None, body_data=body) async def remove_channel_vip(self, broadcaster_id: str, user_id: str) -> bool: """Removes a VIP from the broadcaster’s chat room. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_VIPS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#remove-channel-vip :param broadcaster_id: The ID of the broadcaster that’s removing VIP status from the user. :param user_id: The ID of the user to remove as a VIP from the broadcaster’s chat room. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.TwitchResourceNotFound: if the moderator_id or user_id where not found :returns: True if channel vip was removed, False if user was not a channel vip """ param = { 'user_id': user_id, 'broadcaster_id': broadcaster_id } return await self._build_result('DELETE', 'channels/vips', param, AuthType.USER, [AuthScope.CHANNEL_MANAGE_VIPS], None, result_type=ResultType.STATUS_CODE) == 204 async def add_channel_vip(self, broadcaster_id: str, user_id: str) -> bool: """Adds a VIP to the broadcaster’s chat room. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_VIPS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#add-channel-vip :param broadcaster_id: The ID of the broadcaster that’s granting VIP status to the user. :param user_id: The ID of the user to add as a VIP in the broadcaster’s chat room. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if broadcaster does not have available VIP slots or has not completed the "Build a Community" requirements :raises ~twitchAPI.type.TwitchResourceNotFound: if the broadcaster_id or user_id where not found :returns: True if user was added as vip, False when user was already vip or is moderator """ param = { 'user_id': user_id, 'broadcaster_id': broadcaster_id } error_handler = { 409: ValueError('Broadcaster does not have available VIP slots'), 425: ValueError('The broadcaster did not complete the "Build a Community" requirements') } return await self._build_result('POST', 'channels/vips', param, AuthType.USER, [AuthScope.CHANNEL_MANAGE_VIPS], None, result_type=ResultType.STATUS_CODE, error_handler=error_handler) == 204 async def get_vips(self, broadcaster_id: str, user_ids: Optional[Union[str, List[str]]] = None, first: Optional[int] = None, after: Optional[str] = None) -> AsyncGenerator[ChannelVIP, None]: """Gets a list of the channel’s VIPs. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_VIPS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-vips :param broadcaster_id: The ID of the broadcaster whose list of VIPs you want to get. :param user_ids: Filters the list for specific VIPs. Maximum 100 |default|:code:`None` :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default|:code:`None` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if you specify more than 100 user ids """ if user_ids is not None and isinstance(user_ids, list) and len(user_ids) > 100: raise ValueError('you can only specify up to 100 user ids') param = { 'broadcaster_id': broadcaster_id, 'user_id': user_ids, 'first': first, 'after': after } async for y in self._build_generator('GET', 'channels/vips', param, AuthType.USER, [AuthScope.CHANNEL_READ_VIPS], ChannelVIP, split_lists=True): yield y async def add_channel_moderator(self, broadcaster_id: str, user_id: str): """Adds a moderator to the broadcaster’s chat room. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_MODERATORS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#add-channel-moderator :param broadcaster_id: The ID of the broadcaster that owns the chat room. :param user_id: The ID of the user to add as a moderator in the broadcaster’s chat room. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: If user is a vip """ param = { 'broadcaster_id': broadcaster_id, 'user_id': user_id } error_handler = {422: ValueError('User is a vip')} await self._build_result('POST', 'moderation/moderators', param, AuthType.USER, [AuthScope.CHANNEL_MANAGE_MODERATORS], None, error_handler=error_handler) async def remove_channel_moderator(self, broadcaster_id: str, user_id: str): """Removes a moderator from the broadcaster’s chat room. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_MODERATORS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#remove-channel-moderator :param broadcaster_id: The ID of the broadcaster that owns the chat room. :param user_id: The ID of the user to remove as a moderator from the broadcaster’s chat room. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ param = { 'broadcaster_id': broadcaster_id, 'user_id': user_id } await self._build_result('DELETE', 'moderation/moderators', param, AuthType.USER, [AuthScope.CHANNEL_MANAGE_MODERATORS], None) async def get_user_chat_color(self, user_ids: Union[str, List[str]]) -> List[UserChatColor]: """Gets the color used for the user’s name in chat. Requires User or App Authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-user-chat-color :param user_ids: The ID of the user whose color you want to get. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if you specify more than 100 user ids :return: A list of user chat Colors """ if isinstance(user_ids, list) and len(user_ids) > 100: raise ValueError('you can only request up to 100 users at the same time') return await self._build_result('GET', 'chat/color', {'user_id': user_ids}, AuthType.EITHER, [], List[UserChatColor], split_lists=True) async def update_user_chat_color(self, user_id: str, color: str): """Updates the color used for the user’s name in chat. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.USER_MANAGE_CHAT_COLOR`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#update-user-chat-color :param user_id: The ID of the user whose chat color you want to update. :param color: The color to use for the user’s name in chat. See twitch Docs for valid values. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ param = { 'user_id': user_id, 'color': color } await self._build_result('PUT', 'chat/color', param, AuthType.USER, [AuthScope.USER_MANAGE_CHAT_COLOR], None) async def delete_chat_message(self, broadcaster_id: str, moderator_id: str, message_id: Optional[str] = None): """Removes a single chat message or all chat messages from the broadcaster’s chat room. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_CHAT_MESSAGES`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#delete-chat-messages :param broadcaster_id: The ID of the broadcaster that owns the chat room to remove messages from. :param moderator_id: The ID of a user that has permission to moderate the broadcaster’s chat room. :param message_id: The ID of the message to remove. If None, removes all messages from the broadcasters chat. |default|:code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.ForbiddenError: if moderator_id is not a moderator of broadcaster_id :raises ~twitchAPI.type.TwitchResourceNotFound: if the message_id was not found or the message was created mroe than 6 hours ago """ param = { 'broadcaster_id': broadcaster_id, 'moderator_id': moderator_id, 'message_id': message_id } error = {403: ForbiddenError('moderator_id is not a moderator of broadcaster_id')} await self._build_result('DELETE', 'moderation/chat', param, AuthType.USER, [AuthScope.MODERATOR_MANAGE_CHAT_MESSAGES], None, error_handler=error) async def send_chat_announcement(self, broadcaster_id: str, moderator_id: str, message: str, color: Optional[str] = None): """Sends an announcement to the broadcaster’s chat room. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_ANNOUNCEMENTS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#send-chat-announcement :param broadcaster_id: The ID of the broadcaster that owns the chat room to send the announcement to. :param moderator_id: The ID of a user who has permission to moderate the broadcaster’s chat room. :param message: The announcement to make in the broadcaster’s chat room. :param color: The color used to highlight the announcement. See twitch Docs for valid values. |default|:code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.ForbiddenError: if moderator_id is not a moderator of broadcaster_id """ param = { 'broadcaster_id': broadcaster_id, 'moderator_id': moderator_id } body = remove_none_values({ 'message': message, 'color': color }) error = {403: ForbiddenError('moderator_id is not a moderator of broadcaster_id')} await self._build_result('POST', 'chat/announcements', param, AuthType.USER, [AuthScope.MODERATOR_MANAGE_ANNOUNCEMENTS], None, body_data=body, error_handler=error) async def send_a_shoutout(self, from_broadcaster_id: str, to_broadcaster_id: str, moderator_id: str) -> None: """Sends a Shoutout to the specified broadcaster.\n Typically, you send Shoutouts when you or one of your moderators notice another broadcaster in your chat, the other broadcaster is coming up in conversation, or after they raid your broadcast. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_SHOUTOUTS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#send-a-shoutout :param from_broadcaster_id: The ID of the broadcaster that’s sending the Shoutout. :param to_broadcaster_id: The ID of the broadcaster that’s receiving the Shoutout. :param moderator_id: The ID of the broadcaster or a user that is one of the broadcaster’s moderators. This ID must match the user ID in the access token. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ~twitchAPI.type.TwitchAPIException: if the user in moderator_id is not one of the broadcasters moderators or the broadcaster cant send to_broadcaster_id a shoutout """ param = { 'from_broadcaster_id': from_broadcaster_id, 'to_broadcaster_id': to_broadcaster_id, 'moderator_id': moderator_id } err = {403: TwitchAPIException(f'Forbidden: the user with ID {moderator_id} is not one of the moderators broadcasters or ' f'the broadcaster cant send {to_broadcaster_id} a shoutout')} await self._build_result('POST', 'chat/shoutouts', param, AuthType.USER, [AuthScope.MODERATOR_MANAGE_SHOUTOUTS], None, error_handler=err) async def get_chatters(self, broadcaster_id: str, moderator_id: str, first: Optional[int] = None, after: Optional[str] = None) -> GetChattersResponse: """Gets the list of users that are connected to the broadcaster’s chat session. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.MODERATOR_READ_CHATTERS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-chatters :param broadcaster_id: The ID of the broadcaster whose list of chatters you want to get. :param moderator_id: The ID of the broadcaster or one of the broadcaster’s moderators. This ID must match the user ID in the user access token. :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 1000 |default| :code:`100` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if first is not between 1 and 1000 """ if first is not None and (first < 1 or first > 1000): raise ValueError('first must be between 1 and 1000') param = { 'broadcaster_id': broadcaster_id, 'moderator_id': moderator_id, 'first': first, 'after': after } return await self._build_iter_result('GET', 'chat/chatters', param, AuthType.USER, [AuthScope.MODERATOR_READ_CHATTERS], GetChattersResponse) async def get_shield_mode_status(self, broadcaster_id: str, moderator_id: str) -> ShieldModeStatus: """Gets the broadcaster’s Shield Mode activation status. Requires User Authentication with either :const:`~twitchAPI.type.AuthScope.MODERATOR_READ_SHIELD_MODE` or :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_SHIELD_MODE`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-shield-mode-status :param broadcaster_id: The ID of the broadcaster whose Shield Mode activation status you want to get. :param moderator_id: The ID of the broadcaster or a user that is one of the broadcaster’s moderators. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ param = { 'broadcaster_id': broadcaster_id, 'moderator_id': moderator_id } return await self._build_result('GET', 'moderation/shield_mode', param, AuthType.USER, [[AuthScope.MODERATOR_READ_SHIELD_MODE, AuthScope.MODERATOR_MANAGE_SHIELD_MODE]], ShieldModeStatus) async def update_shield_mode_status(self, broadcaster_id: str, moderator_id: str, is_active: bool) -> ShieldModeStatus: """Activates or deactivates the broadcaster’s Shield Mode. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.MODERATOR_MANAGE_SHIELD_MODE`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#update-shield-mode-status :param broadcaster_id: The ID of the broadcaster whose Shield Mode you want to activate or deactivate. :param moderator_id: The ID of the broadcaster or a user that is one of the broadcaster’s moderators. :param is_active: A Boolean value that determines whether to activate Shield Mode. Set to true to activate Shield Mode; otherwise, false to deactivate Shield Mode. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ param = { 'broadcaster_id': broadcaster_id, 'moderator_id': moderator_id } return await self._build_result('PUT', 'moderation/shield_mode', param, AuthType.USER, [AuthScope.MODERATOR_MANAGE_SHIELD_MODE], ShieldModeStatus, body_data={'is_active': is_active}) async def get_charity_campaign(self, broadcaster_id: str) -> Optional[CharityCampaign]: """Gets information about the charity campaign that a broadcaster is running. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_CHARITY`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-charity-campaign :param broadcaster_id: The ID of the broadcaster that’s currently running a charity campaign. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ return await self._build_result('GET', 'charity/campaigns', {'broadcaster_id': broadcaster_id}, AuthType.USER, [AuthScope.CHANNEL_READ_CHARITY], CharityCampaign) async def get_charity_donations(self, broadcaster_id: str, first: Optional[int] = None, after: Optional[str] = None) -> AsyncGenerator[CharityCampaignDonation, None]: """Gets the list of donations that users have made to the broadcaster’s active charity campaign. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_CHARITY`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-charity-campaign-donations :param broadcaster_id: The ID of the broadcaster that’s currently running a charity campaign. :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default|:code:`20` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ param = { 'broadcaster_id': broadcaster_id, 'first': first, 'after': after } async for y in self._build_generator('GET', 'charity/donations', param, AuthType.USER, [AuthScope.CHANNEL_READ_CHARITY], CharityCampaignDonation): yield y async def get_content_classification_labels(self, locale: Optional[str] = None) -> List[ContentClassificationLabel]: """Gets information about Twitch content classification labels. Requires User or App Authentication\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-content-classification-labels :param locale: Locale for the Content Classification Labels. |default|:code:`en-US` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ return await self._build_result('GET', 'content_classification_labels', {'locale': locale}, AuthType.EITHER, [], List[ContentClassificationLabel]) async def get_ad_schedule(self, broadcaster_id: str) -> AdSchedule: """This endpoint returns ad schedule related information, including snooze, when the last ad was run, when the next ad is scheduled, and if the channel is currently in pre-roll free time. Note that a new ad cannot be run until 8 minutes after running a previous ad. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_READ_ADS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-ad-schedule :param broadcaster_id: Provided broadcaster_id must match the user_id in the auth token. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ return await self._build_result('GET', 'channels/ads', {'broadcaster_id': broadcaster_id}, AuthType.USER, [AuthScope.CHANNEL_READ_ADS], AdSchedule) async def snooze_next_ad(self, broadcaster_id: str) -> AdSnoozeResponse: """If available, pushes back the timestamp of the upcoming automatic mid-roll ad by 5 minutes. This endpoint duplicates the snooze functionality in the creator dashboard’s Ads Manager. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.CHANNEL_MANAGE_ADS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#snooze-next-ad :param broadcaster_id: Provided broadcaster_id must match the user_id in the auth token. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ return await self._build_result('POST', 'channels/ads/schedule/snooze', {'broadcaster_id': broadcaster_id}, AuthType.USER, [AuthScope.CHANNEL_MANAGE_ADS], AdSnoozeResponse) async def send_chat_message(self, broadcaster_id: str, sender_id: str, message: str, reply_parent_message_id: Optional[str] = None) -> SendMessageResponse: """Sends a message to the broadcaster’s chat room. Requires User or App Authentication with :const:`~twitchAPI.type.AuthScope.USER_WRITE_CHAT` \n If App Authorization is used, then additionally requires :const:`~twitchAPI.type.AuthScope.USER_BOT` scope from the chatting user and either :const:`~twitchAPI.type.AuthScope.CHANNEL_BOT` from the broadcaster or moderator status.\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#send-chat-message :param broadcaster_id: The ID of the broadcaster whose chat room the message will be sent to. :param sender_id: The ID of the user sending the message. This ID must match the user ID in the user access token. :param message: The message to send. The message is limited to a maximum of 500 characters. Chat messages can also include emoticons. To include emoticons, use the name of the emote. The names are case sensitive. Don’t include colons around the name (e.g., :bleedPurple:). If Twitch recognizes the name, Twitch converts the name to the emote before writing the chat message to the chat room :param reply_parent_message_id: The ID of the chat message being replied to. :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid """ param = { 'broadcaster_id': broadcaster_id, 'sender_id': sender_id, 'message': message, 'reply_parent_message_id': reply_parent_message_id } return await self._build_result('POST', 'chat/messages', param, AuthType.EITHER, [AuthScope.USER_WRITE_CHAT], SendMessageResponse) async def get_moderated_channels(self, user_id: str, after: Optional[str] = None, first: Optional[int] = None) -> AsyncGenerator[ChannelModerator, None]: """Gets a list of channels that the specified user has moderator privileges in. Requires User Authentication with :const:`~twitchAPI.type.AuthScope.USER_READ_MODERATED_CHANNELS`\n For detailed documentation, see here: https://dev.twitch.tv/docs/api/reference#get-moderated-channels :param user_id: A user’s ID. Returns the list of channels that this user has moderator privileges in. This ID must match the user ID in the user OAuth token :param first: The maximum number of items to return per API call. You can use this in combination with :const:`~twitchAPI.helper.limit()` to optimize the bandwith and number of API calls used to fetch the amount of results you desire.\n Minimum 1, Maximum 100 |default|:code:`20` :param after: Cursor for forward pagination.\n Note: The library handles pagination on its own, only use this parameter if you get a pagination cursor via other means. |default| :code:`None` :raises ~twitchAPI.type.TwitchAPIException: if the request was malformed :raises ~twitchAPI.type.UnauthorizedException: if user authentication is not set or invalid :raises ~twitchAPI.type.MissingScopeException: if the user authentication is missing the required scope :raises ~twitchAPI.type.TwitchAuthorizationException: if the used authentication token became invalid and a re authentication failed :raises ~twitchAPI.type.TwitchBackendException: if the Twitch API itself runs into problems :raises ~twitchAPI.type.TwitchAPIException: if a Query Parameter is missing or invalid :raises ValueError: if first is set and not in range 1 to 100 """ if first is not None and (first < 1 or first > 100): raise ValueError('first has to be between 1 and 100') param = { 'user_id': user_id, 'after': after, 'first': first } async for y in self._build_generator('GET', 'moderation/channels', param, AuthType.USER, [AuthScope.USER_READ_MODERATED_CHANNELS], ChannelModerator): yield y Teekeks-pyTwitchAPI-0d97664/twitchAPI/type.py000066400000000000000000000253651463733066200207510ustar00rootroot00000000000000# Copyright (c) 2020. Lena "Teekeks" During """ Type Definitions ----------------""" from dataclasses import dataclass from enum import Enum from typing_extensions import TypedDict __all__ = ['AnalyticsReportType', 'AuthScope', 'ModerationEventType', 'TimePeriod', 'SortMethod', 'HypeTrainContributionMethod', 'VideoType', 'AuthType', 'StatusCode', 'PubSubResponseError', 'CustomRewardRedemptionStatus', 'SortOrder', 'BlockSourceContext', 'BlockReason', 'EntitlementFulfillmentStatus', 'PollStatus', 'PredictionStatus', 'AutoModAction', 'AutoModCheckEntry', 'DropsEntitlementFulfillmentStatus', 'ChatEvent', 'ChatRoom', 'TwitchAPIException', 'InvalidRefreshTokenException', 'InvalidTokenException', 'NotFoundException', 'TwitchAuthorizationException', 'UnauthorizedException', 'MissingScopeException', 'TwitchBackendException', 'PubSubListenTimeoutException', 'MissingAppSecretException', 'EventSubSubscriptionTimeout', 'EventSubSubscriptionConflict', 'EventSubSubscriptionError', 'DeprecatedError', 'TwitchResourceNotFound', 'ForbiddenError'] class AnalyticsReportType(Enum): """Enum of all Analytics report types """ V1 = 'overview_v1' V2 = 'overview_v2' class AuthScope(Enum): """Enum of Authentication scopes """ ANALYTICS_READ_EXTENSION = 'analytics:read:extensions' ANALYTICS_READ_GAMES = 'analytics:read:games' BITS_READ = 'bits:read' CHANNEL_READ_SUBSCRIPTIONS = 'channel:read:subscriptions' CHANNEL_READ_STREAM_KEY = 'channel:read:stream_key' CHANNEL_EDIT_COMMERCIAL = 'channel:edit:commercial' CHANNEL_READ_HYPE_TRAIN = 'channel:read:hype_train' CHANNEL_MANAGE_BROADCAST = 'channel:manage:broadcast' CHANNEL_READ_REDEMPTIONS = 'channel:read:redemptions' CHANNEL_MANAGE_REDEMPTIONS = 'channel:manage:redemptions' CHANNEL_READ_CHARITY = 'channel:read:charity' CLIPS_EDIT = 'clips:edit' USER_EDIT = 'user:edit' USER_EDIT_BROADCAST = 'user:edit:broadcast' USER_READ_BROADCAST = 'user:read:broadcast' USER_READ_EMAIL = 'user:read:email' USER_EDIT_FOLLOWS = 'user:edit:follows' CHANNEL_MODERATE = 'channel:moderate' CHAT_EDIT = 'chat:edit' CHAT_READ = 'chat:read' WHISPERS_READ = 'whispers:read' WHISPERS_EDIT = 'whispers:edit' MODERATION_READ = 'moderation:read' CHANNEL_SUBSCRIPTIONS = 'channel_subscriptions' CHANNEL_READ_EDITORS = 'channel:read:editors' CHANNEL_MANAGE_VIDEOS = 'channel:manage:videos' USER_READ_BLOCKED_USERS = 'user:read:blocked_users' USER_MANAGE_BLOCKED_USERS = 'user:manage:blocked_users' USER_READ_SUBSCRIPTIONS = 'user:read:subscriptions' USER_READ_FOLLOWS = 'user:read:follows' CHANNEL_READ_GOALS = 'channel:read:goals' CHANNEL_READ_POLLS = 'channel:read:polls' CHANNEL_MANAGE_POLLS = 'channel:manage:polls' CHANNEL_READ_PREDICTIONS = 'channel:read:predictions' CHANNEL_MANAGE_PREDICTIONS = 'channel:manage:predictions' MODERATOR_MANAGE_AUTOMOD = 'moderator:manage:automod' CHANNEL_MANAGE_SCHEDULE = 'channel:manage:schedule' MODERATOR_MANAGE_CHAT_SETTINGS = 'moderator:manage:chat_settings' MODERATOR_MANAGE_BANNED_USERS = 'moderator:manage:banned_users' MODERATOR_READ_BLOCKED_TERMS = 'moderator:read:blocked_terms' MODERATOR_MANAGE_BLOCKED_TERMS = 'moderator:manage:blocked_terms' CHANNEL_MANAGE_RAIDS = 'channel:manage:raids' MODERATOR_MANAGE_ANNOUNCEMENTS = 'moderator:manage:announcements' MODERATOR_MANAGE_CHAT_MESSAGES = 'moderator:manage:chat_messages' USER_MANAGE_CHAT_COLOR = 'user:manage:chat_color' CHANNEL_MANAGE_MODERATORS = 'channel:manage:moderators' CHANNEL_READ_VIPS = 'channel:read:vips' CHANNEL_MANAGE_VIPS = 'channel:manage:vips' USER_MANAGE_WHISPERS = 'user:manage:whispers' MODERATOR_READ_CHATTERS = 'moderator:read:chatters' MODERATOR_READ_SHIELD_MODE = 'moderator:read:shield_mode' MODERATOR_MANAGE_SHIELD_MODE = 'moderator:manage:shield_mode' MODERATOR_READ_AUTOMOD_SETTINGS = 'moderator:read:automod_settings' MODERATOR_MANAGE_AUTOMOD_SETTINGS = 'moderator:manage:automod_settings' MODERATOR_READ_FOLLOWERS = 'moderator:read:followers' MODERATOR_MANAGE_SHOUTOUTS = 'moderator:manage:shoutouts' MODERATOR_READ_SHOUTOUTS = 'moderator:read:shoutouts' CHANNEL_BOT = 'channel:bot' USER_BOT = 'user:bot' USER_READ_CHAT = 'user:read:chat' CHANNEL_READ_ADS = 'channel:read:ads' CHANNEL_MANAGE_ADS = 'channel:manage:ads' USER_WRITE_CHAT = 'user:write:chat' USER_READ_MODERATED_CHANNELS = 'user:read:moderated_channels' class ModerationEventType(Enum): """Enum of moderation event types """ BAN = 'moderation.user.ban' UNBAN = 'moderation.user.unban' UNKNOWN = '' class TimePeriod(Enum): """Enum of valid Time periods """ ALL = 'all' DAY = 'day' WEEK = 'week' MONTH = 'month' YEAR = 'year' class SortMethod(Enum): """Enum of valid sort methods """ TIME = 'time' TRENDING = 'trending' VIEWS = 'views' class HypeTrainContributionMethod(Enum): """Enum of valid Hype Train contribution types """ BITS = 'BITS' SUBS = 'SUBS' OTHER = 'OTHER' UNKNOWN = '' class VideoType(Enum): """Enum of valid video types """ ALL = 'all' UPLOAD = 'upload' ARCHIVE = 'archive' HIGHLIGHT = 'highlight' UNKNOWN = '' class AuthType(Enum): """Type of authentication required. Only internal use """ NONE = 0 USER = 1 APP = 2 EITHER = 3 class StatusCode(Enum): """Enum Code Status, see https://dev.twitch.tv/docs/api/reference#get-code-status for more documentation """ SUCCESSFULLY_REDEEMED = 'SUCCESSFULLY_REDEEMED' ALREADY_CLAIMED = 'ALREADY_CLAIMED' EXPIRED = 'EXPIRED' USER_NOT_ELIGIBLE = 'USER_NOT_ELIGIBLE' NOT_FOUND = 'NOT_FOUND' INACTIVE = 'INACTIVE' UNUSED = 'UNUSED' INCORRECT_FORMAT = 'INCORRECT_FORMAT' INTERNAL_ERROR = 'INTERNAL_ERROR' UNKNOWN_VALUE = '' class PubSubResponseError(Enum): """ """ BAD_MESSAGE = 'ERR_BADMESSAGE' BAD_AUTH = 'ERR_BADAUTH' SERVER = 'ERR_SERVER' BAD_TOPIC = 'ERR_BADTOPIC' NONE = '' UNKNOWN = 'unknown error' class CustomRewardRedemptionStatus(Enum): """ """ UNFULFILLED = 'UNFULFILLED' FULFILLED = 'FULFILLED' CANCELED = 'CANCELED' class SortOrder(Enum): """ """ OLDEST = 'OLDEST' NEWEST = 'NEWEST' class BlockSourceContext(Enum): """ """ CHAT = 'chat' WHISPER = 'whisper' class BlockReason(Enum): """ """ SPAM = 'spam' HARASSMENT = 'harassment' OTHER = 'other' class EntitlementFulfillmentStatus(Enum): """ """ CLAIMED = 'CLAIMED' FULFILLED = 'FULFILLED' class PollStatus(Enum): """ """ ACTIVE = 'ACTIVE' COMPLETED = 'COMPLETED' MODERATED = 'MODERATED' INVALID = 'INVALID' TERMINATED = 'TERMINATED' ARCHIVED = 'ARCHIVED' class PredictionStatus(Enum): """ """ ACTIVE = 'ACTIVE' RESOLVED = 'RESOLVED' CANCELED = 'CANCELED' LOCKED = 'LOCKED' class AutoModAction(Enum): """ """ ALLOW = 'ALLOW' DENY = 'DENY' class DropsEntitlementFulfillmentStatus(Enum): """ """ CLAIMED = 'CLAIMED' FULFILLED = 'FULFILLED' class AutoModCheckEntry(TypedDict): msg_id: str """Developer-generated identifier for mapping messages to results.""" msg_text: str """Message text""" # CHAT class ChatEvent(Enum): """Represents the possible events to listen for using :const:`~twitchAPI.chat.Chat.register_event()`""" READY = 'ready' """Triggered when the bot is started up and ready""" MESSAGE = 'message' """Triggered when someone wrote a message in a chat channel""" SUB = 'sub' """Triggered when someone subscribed to a channel""" RAID = 'raid' """Triggered when a channel gets raided""" ROOM_STATE_CHANGE = 'room_state_change' """Triggered when a chat channel is changed (e.g. sub only mode was enabled)""" JOIN = 'join' """Triggered when someone other than the bot joins a chat channel""" JOINED = 'joined' """Triggered when the bot joins a chat channel""" LEFT = 'left' """Triggered when the bot leaves a chat channel""" USER_LEFT = 'user_left' """Triggered when a user leaves a chat channel""" MESSAGE_DELETE = 'message_delete' """Triggered when a message gets deleted from a channel""" CHAT_CLEARED = 'chat_cleared' """Triggered when a user was banned, timed out or all messaged from a user where deleted""" WHISPER = 'whisper' """Triggered when someone whispers to your bot. NOTE: You need the :const:`~twitchAPI.type.AuthScope.WHISPERS_READ` Auth Scope to get this Event.""" NOTICE = 'notice' """Triggerd on server notice""" @dataclass class ChatRoom: name: str is_emote_only: bool is_subs_only: bool is_followers_only: bool is_unique_only: bool follower_only_delay: int room_id: str slow: int # EXCEPTIONS class TwitchAPIException(Exception): """Base Twitch API Exception""" pass class InvalidRefreshTokenException(TwitchAPIException): """used User Refresh Token is invalid""" pass class InvalidTokenException(TwitchAPIException): """Used if a invalid token is set for the client""" pass class NotFoundException(TwitchAPIException): """Resource was not found with the given parameter""" pass class TwitchAuthorizationException(TwitchAPIException): """Exception in the Twitch Authorization""" pass class UnauthorizedException(TwitchAuthorizationException): """Not authorized to use this""" pass class MissingScopeException(TwitchAuthorizationException): """authorization is missing scope""" pass class TwitchBackendException(TwitchAPIException): """when the Twitch API itself is down""" pass class PubSubListenTimeoutException(TwitchAPIException): """when a a PubSub listen command times out""" pass class MissingAppSecretException(TwitchAPIException): """When the app secret is not set but app authorization is attempted""" pass class EventSubSubscriptionTimeout(TwitchAPIException): """When the waiting for a confirmed EventSub subscription timed out""" pass class EventSubSubscriptionConflict(TwitchAPIException): """When you try to subscribe to a EventSub subscription that already exists""" pass class EventSubSubscriptionError(TwitchAPIException): """if the subscription request was invalid""" pass class DeprecatedError(TwitchAPIException): """If something has been marked as deprecated by the Twitch API""" pass class TwitchResourceNotFound(TwitchAPIException): """If a requested resource was not found""" pass class ForbiddenError(TwitchAPIException): """If you are not allowed to do that""" pass