Skip to content

Commit cb55aa6

Browse files
committed
First real commit
1 parent a4c5e23 commit cb55aa6

27 files changed

+1044
-1
lines changed

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,6 @@ cython_debug/
172172

173173
# PyPI configuration file
174174
.pypirc
175+
176+
*.token.json
177+
test_*.py

LICENSE

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2025 Predä
3+
Copyright (c) 2025 - Present Predä
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
# kickcom.py
2+
23
Async library for Kick.com API and webhooks
4+
5+
> [!NOTE]
6+
> This is in alpha stage, it currently only supports app access tokens. PRs to improve are very welcome!

pyproject.toml

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
[build-system]
2+
requires = ["flit_core >= 3.4"]
3+
build-backend = "flit_core.buildapi"
4+
5+
[project]
6+
name = "Kickcom.py"
7+
readme = "README.md"
8+
authors = [
9+
{name = "PredaaA"},
10+
]
11+
license = {file = "LICENSE"}
12+
classifiers = [
13+
"Development Status :: 3 - Alpha",
14+
"Intended Audience :: Developers",
15+
"License :: OSI Approved :: MIT License",
16+
"Operating System :: OS Independent",
17+
"Programming Language :: Python :: 3 :: Only",
18+
"Programming Language :: Python :: 3.9",
19+
"Programming Language :: Python :: 3.10",
20+
"Programming Language :: Python :: 3.11",
21+
"Programming Language :: Python :: 3.12",
22+
"Programming Language :: Python :: 3.13",
23+
]
24+
requires-python = ">=3.8"
25+
dynamic = ["version", "description"]
26+
dependencies = [
27+
"aiohttp>=3.11.14",
28+
]
29+
30+
[project.optional-dependencies]
31+
speed = [
32+
"orjson>=3.10.16",
33+
"aiodns>=1.1; sys_platform != 'win32'",
34+
"Brotli",
35+
"cchardet==2.1.7; python_version < '3.10'"
36+
]
37+
38+
[project.urls]
39+
"Issue Tracker" = "https://github.com/PredaaA/kickcom.py/issues"
40+
"Source Code" = "https://github.com/PredaaA/kickcom.py"
41+
42+
[tool.flit.module]
43+
name = "kickpy"
44+
45+
[tool.black]
46+
line-length = 99
47+
target-version = ["py313"]
48+
49+
[tool.isort]
50+
profile = "black"
51+
line_length = 99
52+
combine_as_imports = true
53+
filter_files = true
54+
55+
[tool.ruff]
56+
target-version = "py313"
57+
line-length = 99
58+
select = ["C90", "E", "F", "I001", "PGH004", "RUF100"]
59+
fix = true
60+
fixable = ["I001"]
61+
isort.combine-as-imports = true
62+
force-exclude = true

src/kickpy/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__version__ = "0.0.1"

src/kickpy/client.py

+236
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
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})

src/kickpy/errors.py

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from aiohttp import ClientResponse
2+
3+
4+
class KickpyException(Exception):
5+
"""Base exception class for Kickpy errors."""
6+
7+
8+
class NoClientId(KickpyException):
9+
"""Raised when no client_id is provided."""
10+
11+
12+
class NoClientSecret(KickpyException):
13+
"""Raised when no client_secret is provided."""
14+
15+
16+
class HTTPException(KickpyException):
17+
"""Base exception class for HTTP errors."""
18+
19+
def __init__(self, response: ClientResponse, message: str | None):
20+
self.response: ClientResponse = response
21+
self.status: int = response.status
22+
self.message: str | None = message
23+
24+
super().__init__(message)
25+
26+
27+
class NotFound(HTTPException):
28+
"""Raised when a resource is not found."""
29+
30+
31+
class Unauthorized(HTTPException):
32+
"""Raised when a request is unauthorized."""
33+
34+
35+
class BadRequest(HTTPException):
36+
"""Raised when a request is bad."""
37+
38+
39+
class Forbidden(HTTPException):
40+
"""Raised when a request is forbidden."""
41+
42+
43+
class Ratelimited(HTTPException):
44+
"""Raised when a request is ratelimited."""
45+
46+
# def __init__(self, response: ClientResponse, message: str | None, retry_after: int):
47+
# super().__init__(response, message)
48+
# self.retry_after: int = retry_after
49+
50+
51+
class InternalServerError(HTTPException):
52+
"""Raised when a server error occurs."""

0 commit comments

Comments
 (0)