|
| 1 | +import logging |
| 2 | +from datetime import datetime |
| 3 | +from typing import Any, Dict, Union |
| 4 | + |
| 5 | +import aiohttp |
| 6 | + |
| 7 | +from kickpy import utils |
| 8 | +from kickpy.errors import ( |
| 9 | + BadRequest, |
| 10 | + Forbidden, |
| 11 | + InternalServerError, |
| 12 | + NotFound, |
| 13 | + Ratelimited, |
| 14 | + Unauthorized, |
| 15 | +) |
| 16 | +from kickpy.logger import init_logging |
| 17 | +from kickpy.models.access_token import AccessToken |
| 18 | +from kickpy.models.categories import Category |
| 19 | +from kickpy.models.channel import Channel |
| 20 | +from kickpy.models.events_subscriptions import EventsSubscription, EventsSubscriptionCreated |
| 21 | +from kickpy.models.user import User |
| 22 | +from kickpy.webhooks.enums import WebhookEvent |
| 23 | + |
| 24 | +logging_listener = init_logging() |
| 25 | +log = logging.getLogger(__name__) |
| 26 | + |
| 27 | + |
| 28 | +async def json_or_text(response: aiohttp.ClientResponse) -> Union[Dict[str, Any], str]: |
| 29 | + text = await response.text(encoding="utf-8") |
| 30 | + if response.headers.get("Content-Type") == "application/json": |
| 31 | + return utils.json_loads(text) |
| 32 | + |
| 33 | + return text |
| 34 | + |
| 35 | + |
| 36 | +class KickClient: |
| 37 | + def __init__( |
| 38 | + self, client_id: str, client_secret: str, launch_webhook_server: bool = False |
| 39 | + ) -> None: |
| 40 | + self.client_id = client_id |
| 41 | + self.client_secret = client_secret |
| 42 | + self._access_token: AccessToken | None = None |
| 43 | + |
| 44 | + self.id_session = aiohttp.ClientSession(base_url="https://id.kick.com") |
| 45 | + self.api_session = aiohttp.ClientSession(base_url="https://api.kick.com/public/v1/") |
| 46 | + |
| 47 | + async def close(self): |
| 48 | + """Close the client and all tasks.""" |
| 49 | + await self.id_session.close() |
| 50 | + await self.api_session.close() |
| 51 | + logging_listener.stop() |
| 52 | + |
| 53 | + async def _fetch_api(self, method: str, endpoint: str, **kwargs) -> dict: |
| 54 | + token = await self._fetch_access_token() |
| 55 | + |
| 56 | + async with self.api_session.request( |
| 57 | + method, |
| 58 | + endpoint, |
| 59 | + headers={"Authorization": f"Bearer {token.access_token}"}, |
| 60 | + **kwargs, |
| 61 | + ) as resp: |
| 62 | + if resp.status == 400: |
| 63 | + raise BadRequest(resp) |
| 64 | + |
| 65 | + if resp.status == 401: |
| 66 | + raise Unauthorized(resp) |
| 67 | + |
| 68 | + if resp.status == 403: |
| 69 | + raise Forbidden(resp) |
| 70 | + |
| 71 | + if resp.status == 404: |
| 72 | + raise NotFound(resp) |
| 73 | + |
| 74 | + # TODO: Implement proper ratelimit handling |
| 75 | + if resp.status == 429: |
| 76 | + raise Ratelimited(resp) |
| 77 | + |
| 78 | + if resp.status >= 500: |
| 79 | + raise InternalServerError(resp) |
| 80 | + |
| 81 | + data = await json_or_text(resp) |
| 82 | + |
| 83 | + return data |
| 84 | + |
| 85 | + async def _fetch_access_token(self) -> AccessToken: |
| 86 | + if self._access_token and self._access_token.expires_at > datetime.now(): |
| 87 | + return self._access_token |
| 88 | + |
| 89 | + try: |
| 90 | + with open(".kick.token.json", "r") as f: |
| 91 | + json_data = utils.json_loads(f.read()) |
| 92 | + access_token = AccessToken.from_dict(json_data) |
| 93 | + if access_token.expires_at > datetime.now(): |
| 94 | + self._access_token = access_token |
| 95 | + return access_token |
| 96 | + |
| 97 | + log.info("Token expired, fetching a new one...") |
| 98 | + except (FileNotFoundError, Exception): |
| 99 | + pass |
| 100 | + |
| 101 | + async with self.id_session.post( |
| 102 | + "/oauth/token", |
| 103 | + data={ |
| 104 | + "grant_type": "client_credentials", |
| 105 | + "client_id": self.client_id, |
| 106 | + "client_secret": self.client_secret, |
| 107 | + }, |
| 108 | + ) as resp: |
| 109 | + if resp.status != 200: |
| 110 | + raise InternalServerError(resp, "Failed to fetch access token.") |
| 111 | + |
| 112 | + data = await resp.json() |
| 113 | + |
| 114 | + access_token = AccessToken.from_dict(data) |
| 115 | + with open(".kick.token.json", "w+") as f: |
| 116 | + f.write(utils.json_dumps(access_token.to_dict())) |
| 117 | + |
| 118 | + self._access_token = access_token |
| 119 | + return access_token |
| 120 | + |
| 121 | + async def fetch_public_key(self) -> bytes: |
| 122 | + """Get the public key of the Kick.com API. |
| 123 | +
|
| 124 | + Returns |
| 125 | + ------- |
| 126 | + str |
| 127 | + The public key data. |
| 128 | + """ |
| 129 | + data = await self._fetch_api("GET", "public-key") |
| 130 | + |
| 131 | + public_key: str = data["data"]["public_key"] |
| 132 | + return public_key.encode() |
| 133 | + |
| 134 | + async def fetch_user(self, user_id: int) -> User: |
| 135 | + """Get a user by their ID. |
| 136 | +
|
| 137 | + Parameters |
| 138 | + ---------- |
| 139 | + user_id: int |
| 140 | + The ID of the user to get. |
| 141 | +
|
| 142 | + Returns |
| 143 | + ------- |
| 144 | + User |
| 145 | + The user data. |
| 146 | + """ |
| 147 | + data = await self._fetch_api("GET", "users", params={"id": user_id}) |
| 148 | + return User(**data["data"][0]) |
| 149 | + |
| 150 | + async def fetch_channel(self, user_id: int) -> Channel: |
| 151 | + """Get a channel by the broadcaster user ID. |
| 152 | +
|
| 153 | + Parameters |
| 154 | + ---------- |
| 155 | + user_id: int |
| 156 | + The broadcaster user ID. |
| 157 | +
|
| 158 | + Returns |
| 159 | + ------- |
| 160 | + Channel |
| 161 | + The channel data. |
| 162 | + """ |
| 163 | + data = await self._fetch_api("GET", "channels", params={"broadcaster_user_id": user_id}) |
| 164 | + return Channel.from_dict(data["data"][0]) |
| 165 | + |
| 166 | + async def fetch_categories(self, query: str) -> list[Category]: |
| 167 | + """Get categories by a query. |
| 168 | +
|
| 169 | + Parameters |
| 170 | + ---------- |
| 171 | + query: str |
| 172 | + The query to search for. |
| 173 | +
|
| 174 | + Returns |
| 175 | + ------- |
| 176 | + list[Category] |
| 177 | + A list of categories data. |
| 178 | + """ |
| 179 | + data = await self._fetch_api("GET", "categories", params={"query": query}) |
| 180 | + return [Category(**category) for category in data["data"]] |
| 181 | + |
| 182 | + async def fetch_events_subscriptions(self) -> list[EventsSubscription]: |
| 183 | + """Get event subscriptions. |
| 184 | +
|
| 185 | + Returns |
| 186 | + ------- |
| 187 | + list[EventsSubscription] |
| 188 | + A list of EventsSubscription data. |
| 189 | + """ |
| 190 | + data = await self._fetch_api("GET", "events/subscriptions") |
| 191 | + return [EventsSubscription.from_dict(sub) for sub in data["data"]] |
| 192 | + |
| 193 | + async def subscribe_to_event( |
| 194 | + self, event_type: WebhookEvent, user_id: int |
| 195 | + ) -> EventsSubscriptionCreated: |
| 196 | + """Subscribe to an event. |
| 197 | +
|
| 198 | + Parameters |
| 199 | + ---------- |
| 200 | + event_type: WebhookEvent |
| 201 | + The event type to subscribe to. |
| 202 | + user_id: int |
| 203 | + The user ID to subscribe to. |
| 204 | +
|
| 205 | + Returns |
| 206 | + ------- |
| 207 | + EventsSubscriptionCreated |
| 208 | + The created event subscription if successful, otherwise None. |
| 209 | + """ |
| 210 | + request_data = { |
| 211 | + "events": [ |
| 212 | + { |
| 213 | + "name": event_type.value, |
| 214 | + "version": 1, |
| 215 | + } |
| 216 | + ], |
| 217 | + "broadcaster_user_id": user_id, |
| 218 | + "method": "webhook", |
| 219 | + } |
| 220 | + data = await self._fetch_api("POST", "events/subscriptions", json=request_data) |
| 221 | + return EventsSubscriptionCreated(**data["data"][0]) |
| 222 | + |
| 223 | + async def unsubscribe_from_event(self, subscription_id: str) -> None: |
| 224 | + """Unsubscribe from an event. |
| 225 | +
|
| 226 | + Parameters |
| 227 | + ---------- |
| 228 | + subscription_id: str |
| 229 | + The subscription ID to unsubscribe from. |
| 230 | +
|
| 231 | + Returns |
| 232 | + ------- |
| 233 | + bool |
| 234 | + True if successful, otherwise False. |
| 235 | + """ |
| 236 | + await self._fetch_api("DELETE", "events/subscriptions", params={"id": subscription_id}) |
0 commit comments