fazercards-python-sdk 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fazercards_python_sdk-0.1.1.dist-info/METADATA +169 -0
- fazercards_python_sdk-0.1.1.dist-info/RECORD +18 -0
- fazercards_python_sdk-0.1.1.dist-info/WHEEL +4 -0
- fazercards_python_sdk-0.1.1.dist-info/licenses/LICENSE +21 -0
- fazercards_sdk/__init__.py +102 -0
- fazercards_sdk/_core.py +308 -0
- fazercards_sdk/_version.py +1 -0
- fazercards_sdk/client.py +58 -0
- fazercards_sdk/config.py +104 -0
- fazercards_sdk/enums.py +90 -0
- fazercards_sdk/errors.py +104 -0
- fazercards_sdk/messages.py +60 -0
- fazercards_sdk/messages.yaml +48 -0
- fazercards_sdk/models.py +515 -0
- fazercards_sdk/money.py +41 -0
- fazercards_sdk/py.typed +0 -0
- fazercards_sdk/resources.py +313 -0
- fazercards_sdk/webhooks.py +63 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any, Mapping, Optional, Union
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from ._core import Flow, Op, Pause
|
|
11
|
+
from .enums import PREMIUM_MONTHS, SteamCurrency
|
|
12
|
+
from .errors import InvalidRequestError, WaitTimeoutError
|
|
13
|
+
from .messages import text
|
|
14
|
+
from .models import (
|
|
15
|
+
Account,
|
|
16
|
+
Balance,
|
|
17
|
+
Category,
|
|
18
|
+
CategoryPage,
|
|
19
|
+
Offers,
|
|
20
|
+
Order,
|
|
21
|
+
OrderPage,
|
|
22
|
+
PlayerCheck,
|
|
23
|
+
PremiumQuote,
|
|
24
|
+
StarsQuote,
|
|
25
|
+
SteamLogin,
|
|
26
|
+
SteamRates,
|
|
27
|
+
ValidationGame,
|
|
28
|
+
Webhook,
|
|
29
|
+
)
|
|
30
|
+
from .money import Amount, amount, wire
|
|
31
|
+
|
|
32
|
+
USERNAME = re.compile(r"^@?([A-Za-z0-9_]{4,32})$")
|
|
33
|
+
CATEGORY_PAGE_MAX = 500
|
|
34
|
+
ORDER_PAGE_MAX = 100
|
|
35
|
+
GIFT_CARD_MAX = 100
|
|
36
|
+
STARS_MIN = 50
|
|
37
|
+
STARS_MAX = 100000
|
|
38
|
+
|
|
39
|
+
CurrencyLike = Union[SteamCurrency, str]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def required(value: Any, name: str, limit: int = 255) -> str:
|
|
43
|
+
result = str(value if value is not None else "").strip()
|
|
44
|
+
if not result:
|
|
45
|
+
raise InvalidRequestError(text("value.empty", field=name))
|
|
46
|
+
if len(result) > limit:
|
|
47
|
+
raise InvalidRequestError(text("value.too_long", field=name, limit=limit))
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def whole(value: Any, name: str, low: int, high: int) -> int:
|
|
52
|
+
if isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high:
|
|
53
|
+
raise InvalidRequestError(text("value.range", field=name, low=low, high=high, value=value))
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def order_id(value: Any) -> str:
|
|
58
|
+
result = required(value, "order_id", 64)
|
|
59
|
+
if "/" in result or "?" in result:
|
|
60
|
+
raise InvalidRequestError(text("value.order_id", value=value))
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def username(value: Any) -> str:
|
|
65
|
+
match = USERNAME.match(str(value or "").strip())
|
|
66
|
+
if not match:
|
|
67
|
+
raise InvalidRequestError(text("value.username", value=value))
|
|
68
|
+
return match.group(1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def player_fields(fields: Optional[Mapping[str, Any]], **named: Any) -> dict[str, str]:
|
|
72
|
+
if fields is not None and not isinstance(fields, Mapping):
|
|
73
|
+
raise InvalidRequestError(text("value.fields", value=fields))
|
|
74
|
+
merged = {**dict(fields or {}), **{k: v for k, v in named.items() if v is not None}}
|
|
75
|
+
result = {}
|
|
76
|
+
for key, value in merged.items():
|
|
77
|
+
if value is None or isinstance(value, (dict, list, tuple, set)):
|
|
78
|
+
raise InvalidRequestError(text("value.fields", value=fields))
|
|
79
|
+
result[required(key, "field name", 64)] = required(value, str(key), 255)
|
|
80
|
+
if not result:
|
|
81
|
+
raise InvalidRequestError(text("value.empty", field="fields"))
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def https_url(value: Any) -> str:
|
|
86
|
+
result = str(value or "").strip()
|
|
87
|
+
parts = urlsplit(result)
|
|
88
|
+
if parts.scheme != "https" or not parts.hostname:
|
|
89
|
+
raise InvalidRequestError(text("value.url", value=value))
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _list_of(model: Any, key: str) -> Any:
|
|
94
|
+
return lambda body: tuple(model.from_api(item) for item in (body.get(key) or []) if isinstance(item, Mapping))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _Resource:
|
|
98
|
+
def __init__(self, client: Any) -> None:
|
|
99
|
+
self._client = client
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def _settings(self) -> Any:
|
|
103
|
+
return self._client.settings
|
|
104
|
+
|
|
105
|
+
def _key(self, given: Optional[str]) -> str:
|
|
106
|
+
return self._settings.scoped(required(given, "idempotency_key", 200) if given else uuid.uuid4().hex)
|
|
107
|
+
|
|
108
|
+
def _run(self, op: Op) -> Any:
|
|
109
|
+
return self._client._run(op)
|
|
110
|
+
|
|
111
|
+
def _flow(self, flow: Flow[Any]) -> Any:
|
|
112
|
+
return self._client._flow(flow)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _Catalog(_Resource):
|
|
116
|
+
_categories = ""
|
|
117
|
+
_offers = ""
|
|
118
|
+
|
|
119
|
+
def categories(self, limit: int = 100, cursor: Optional[str] = None, include_ui: bool = True) -> Any:
|
|
120
|
+
query = {"limit": whole(limit, "limit", 1, CATEGORY_PAGE_MAX), "cursor": cursor,
|
|
121
|
+
"include_ui": 1 if include_ui else None}
|
|
122
|
+
return self._run(Op("GET", self._categories, CategoryPage.from_api, query=query, safe=True))
|
|
123
|
+
|
|
124
|
+
def all_categories(self, include_ui: bool = True, max_pages: int = 50) -> Any:
|
|
125
|
+
return self._flow(self._every_category(include_ui, max_pages))
|
|
126
|
+
|
|
127
|
+
def _every_category(self, include_ui: bool, max_pages: int) -> Flow[tuple[Category, ...]]:
|
|
128
|
+
items: list[Category] = []
|
|
129
|
+
cursor: Optional[str] = None
|
|
130
|
+
for _ in range(max(1, max_pages)):
|
|
131
|
+
query = {"limit": CATEGORY_PAGE_MAX, "cursor": cursor, "include_ui": 1 if include_ui else None}
|
|
132
|
+
page: CategoryPage = yield Op("GET", self._categories, CategoryPage.from_api, query=query, safe=True)
|
|
133
|
+
items.extend(page.items)
|
|
134
|
+
cursor = page.next_cursor if page.has_more else None
|
|
135
|
+
if not cursor:
|
|
136
|
+
break
|
|
137
|
+
return tuple(items)
|
|
138
|
+
|
|
139
|
+
def offers(self, category_id: str, include_ui: bool = True) -> Any:
|
|
140
|
+
query = {"category_id": required(category_id, "category_id"), "include_ui": 1 if include_ui else None}
|
|
141
|
+
return self._run(Op("GET", self._offers, Offers.from_api, query=query, safe=True))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Topups(_Catalog):
|
|
145
|
+
_categories = "topup_categories"
|
|
146
|
+
_offers = "topup_offers"
|
|
147
|
+
|
|
148
|
+
def validation_games(self) -> Any:
|
|
149
|
+
return self._run(Op("GET", "validate_id", _list_of(ValidationGame, "items"), safe=True))
|
|
150
|
+
|
|
151
|
+
def validate(
|
|
152
|
+
self,
|
|
153
|
+
category_id: str,
|
|
154
|
+
player_id: Optional[str] = None,
|
|
155
|
+
*,
|
|
156
|
+
server_id: Optional[str] = None,
|
|
157
|
+
fields: Optional[Mapping[str, Any]] = None,
|
|
158
|
+
) -> Any:
|
|
159
|
+
body = {"category_id": required(category_id, "category_id"),
|
|
160
|
+
"fields": player_fields(fields, player_id=player_id, server_id=server_id)}
|
|
161
|
+
return self._run(Op("POST", "validate_id", PlayerCheck.from_api, body=body, safe=True))
|
|
162
|
+
|
|
163
|
+
def order(
|
|
164
|
+
self,
|
|
165
|
+
category_id: str,
|
|
166
|
+
offer_id: str,
|
|
167
|
+
player_id: Optional[str] = None,
|
|
168
|
+
*,
|
|
169
|
+
server_id: Optional[str] = None,
|
|
170
|
+
fields: Optional[Mapping[str, Any]] = None,
|
|
171
|
+
idempotency_key: Optional[str] = None,
|
|
172
|
+
) -> Any:
|
|
173
|
+
category, offer = required(category_id, "category_id"), required(offer_id, "offer_id")
|
|
174
|
+
given = player_fields(fields, player_id=player_id, server_id=server_id)
|
|
175
|
+
key = self._key(idempotency_key)
|
|
176
|
+
op = Op("POST", "topup_order", lambda body: Order.from_api(body).with_key(key),
|
|
177
|
+
body={"category_id": category, "offer_id": offer, "fields": given}, key=key,
|
|
178
|
+
echo={"category_id": category, "offer_id": offer, "player_id": given.get("player_id")})
|
|
179
|
+
return self._run(op)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class GiftCards(_Catalog):
|
|
183
|
+
_categories = "giftcard_categories"
|
|
184
|
+
_offers = "giftcard_offers"
|
|
185
|
+
|
|
186
|
+
def cards(self, category_id: str, include_ui: bool = True) -> Any:
|
|
187
|
+
return self.offers(category_id, include_ui)
|
|
188
|
+
|
|
189
|
+
def order(
|
|
190
|
+
self,
|
|
191
|
+
category_id: str,
|
|
192
|
+
card_id: str,
|
|
193
|
+
quantity: int = 1,
|
|
194
|
+
*,
|
|
195
|
+
idempotency_key: Optional[str] = None,
|
|
196
|
+
wait_for_codes: bool = True,
|
|
197
|
+
) -> Any:
|
|
198
|
+
category, card = required(category_id, "category_id"), required(card_id, "card_id")
|
|
199
|
+
count = whole(quantity, "quantity", 1, GIFT_CARD_MAX)
|
|
200
|
+
key = self._key(idempotency_key)
|
|
201
|
+
op = Op("POST", "giftcard_order", lambda body: Order.from_api(body).with_key(key),
|
|
202
|
+
body={"category_id": category, "card_id": card, "quantity": count}, key=key,
|
|
203
|
+
echo={"category_id": category, "offer_id": card})
|
|
204
|
+
checks = self._settings.code_checks if wait_for_codes else 0
|
|
205
|
+
return self._flow(self._codes(op, key, checks, self._settings.code_check_wait))
|
|
206
|
+
|
|
207
|
+
def _codes(self, op: Op, key: str, checks: int, wait: float) -> Flow[Order]:
|
|
208
|
+
order: Order = yield op
|
|
209
|
+
for attempt in range(checks):
|
|
210
|
+
if not order.awaiting_codes or not order.id:
|
|
211
|
+
break
|
|
212
|
+
if attempt:
|
|
213
|
+
yield Pause(wait)
|
|
214
|
+
fresh: Order = yield Op("GET", "order", Order.from_api, path_params={"order_id": order.id}, safe=True)
|
|
215
|
+
order = fresh.with_key(key)
|
|
216
|
+
return order
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class Orders(_Resource):
|
|
220
|
+
def get(self, order_id_: str) -> Any:
|
|
221
|
+
return self._run(Op("GET", "order", Order.from_api, path_params={"order_id": order_id(order_id_)}, safe=True))
|
|
222
|
+
|
|
223
|
+
def list(self, page: int = 1, limit: int = 20) -> Any:
|
|
224
|
+
query = {"page": whole(page, "page", 1, 10**9), "limit": whole(limit, "limit", 1, ORDER_PAGE_MAX)}
|
|
225
|
+
return self._run(Op("GET", "orders", OrderPage.from_api, query=query, safe=True))
|
|
226
|
+
|
|
227
|
+
def recent(self, count: int = 100) -> Any:
|
|
228
|
+
return self._flow(self._recent(whole(count, "count", 1, 10**6)))
|
|
229
|
+
|
|
230
|
+
def _recent(self, count: int) -> Flow[tuple[Order, ...]]:
|
|
231
|
+
items: list[Order] = []
|
|
232
|
+
page = 1
|
|
233
|
+
while len(items) < count:
|
|
234
|
+
query = {"page": page, "limit": min(ORDER_PAGE_MAX, count - len(items))}
|
|
235
|
+
batch: OrderPage = yield Op("GET", "orders", OrderPage.from_api, query=query, safe=True)
|
|
236
|
+
items.extend(batch.items)
|
|
237
|
+
if not batch.items or not batch.has_more:
|
|
238
|
+
break
|
|
239
|
+
page += 1
|
|
240
|
+
return tuple(items[:count])
|
|
241
|
+
|
|
242
|
+
def wait(self, order_id_: str, timeout: float = 120.0, interval: float = 5.0) -> Any:
|
|
243
|
+
return self._flow(self._wait(order_id(order_id_), timeout, max(0.0, interval)))
|
|
244
|
+
|
|
245
|
+
def _wait(self, wanted: str, timeout: float, interval: float) -> Flow[Order]:
|
|
246
|
+
deadline = time.monotonic() + timeout
|
|
247
|
+
while True:
|
|
248
|
+
order: Order = yield Op("GET", "order", Order.from_api, path_params={"order_id": wanted}, safe=True)
|
|
249
|
+
if order.status.final and not order.awaiting_codes:
|
|
250
|
+
return order
|
|
251
|
+
if time.monotonic() + interval > deadline:
|
|
252
|
+
raise WaitTimeoutError(text("error.wait_timeout", order_id=wanted, status=order.status,
|
|
253
|
+
seconds=timeout), order=order)
|
|
254
|
+
yield Pause(interval)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class Telegram(_Resource):
|
|
258
|
+
def stars_quote(self) -> Any:
|
|
259
|
+
return self._run(Op("GET", "stars_quote", StarsQuote.from_api, safe=True))
|
|
260
|
+
|
|
261
|
+
def premium_quote(self) -> Any:
|
|
262
|
+
return self._run(Op("GET", "premium_quote", PremiumQuote.from_api, safe=True))
|
|
263
|
+
|
|
264
|
+
def buy_stars(self, username_: str, quantity: int) -> Any:
|
|
265
|
+
body = {"telegram_username": username(username_), "quantity": whole(quantity, "quantity", STARS_MIN, STARS_MAX)}
|
|
266
|
+
return self._run(Op("POST", "stars_buy", Order.from_api, body=body))
|
|
267
|
+
|
|
268
|
+
def buy_premium(self, username_: str, months: int) -> Any:
|
|
269
|
+
if months not in PREMIUM_MONTHS or isinstance(months, bool):
|
|
270
|
+
raise InvalidRequestError(text("value.choice", field="months", choices=PREMIUM_MONTHS, value=months))
|
|
271
|
+
body = {"telegram_username": username(username_), "months": months}
|
|
272
|
+
return self._run(Op("POST", "premium_buy", Order.from_api, body=body))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class Steam(_Resource):
|
|
276
|
+
def rates(self) -> Any:
|
|
277
|
+
return self._run(Op("GET", "steam_rates", SteamRates.from_api, safe=True))
|
|
278
|
+
|
|
279
|
+
def check_login(self, login: str) -> Any:
|
|
280
|
+
body = {"steamLogin": required(login, "login", 64)}
|
|
281
|
+
return self._run(Op("POST", "steam_login", SteamLogin.from_api, body=body, safe=True))
|
|
282
|
+
|
|
283
|
+
def order(
|
|
284
|
+
self,
|
|
285
|
+
login: str,
|
|
286
|
+
amount_: Amount,
|
|
287
|
+
currency: CurrencyLike = SteamCurrency.USD,
|
|
288
|
+
*,
|
|
289
|
+
idempotency_key: Optional[str] = None,
|
|
290
|
+
) -> Any:
|
|
291
|
+
try:
|
|
292
|
+
code = str(SteamCurrency(str(currency).upper()))
|
|
293
|
+
except ValueError:
|
|
294
|
+
choices = tuple(str(item) for item in SteamCurrency)
|
|
295
|
+
raise InvalidRequestError(text("value.choice", field="currency", choices=choices, value=currency)) from None
|
|
296
|
+
value: Decimal = amount(amount_)
|
|
297
|
+
key = self._key(idempotency_key)
|
|
298
|
+
body = {"steamLogin": required(login, "login", 64), "currency": code, "amount": wire(value)}
|
|
299
|
+
return self._run(Op("POST", "steam_order", lambda data: Order.from_api(data).with_key(key), body=body, key=key))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class AccountResource(_Resource):
|
|
303
|
+
def me(self) -> Any:
|
|
304
|
+
return self._run(Op("GET", "me", Account.from_api, safe=True))
|
|
305
|
+
|
|
306
|
+
def balance(self) -> Any:
|
|
307
|
+
return self._run(Op("GET", "balance", Balance.from_api, safe=True))
|
|
308
|
+
|
|
309
|
+
def webhook(self) -> Any:
|
|
310
|
+
return self._run(Op("GET", "webhook", Webhook.from_api, safe=True))
|
|
311
|
+
|
|
312
|
+
def set_webhook(self, url: str) -> Any:
|
|
313
|
+
return self._run(Op("PUT", "webhook", Webhook.from_api, body={"url": https_url(url)}, safe=True))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Mapping, Union
|
|
7
|
+
|
|
8
|
+
from .errors import ConfigurationError, SignatureError
|
|
9
|
+
from .messages import text
|
|
10
|
+
from .models import WebhookEvent
|
|
11
|
+
|
|
12
|
+
SIGNATURE_HEADER = "X-Webhook-Signature"
|
|
13
|
+
MAX_PAYLOAD = 256 * 1024
|
|
14
|
+
MAX_SIGNATURE = 200
|
|
15
|
+
|
|
16
|
+
Payload = Union[bytes, bytearray, str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _bytes(payload: Payload) -> bytes:
|
|
20
|
+
return payload.encode("utf-8") if isinstance(payload, str) else bytes(payload)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sign(secret: str, payload: Payload) -> str:
|
|
24
|
+
return "sha256=" + hmac.new(secret.encode("utf-8"), _bytes(payload), hashlib.sha256).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def matches(secret: str, payload: Payload, signature: str) -> bool:
|
|
28
|
+
if not secret or not signature or len(signature) > MAX_SIGNATURE:
|
|
29
|
+
return False
|
|
30
|
+
body = _bytes(payload)
|
|
31
|
+
if len(body) > MAX_PAYLOAD:
|
|
32
|
+
return False
|
|
33
|
+
expected = sign(secret, body).removeprefix("sha256=")
|
|
34
|
+
received = signature.strip().removeprefix("sha256=").lower()
|
|
35
|
+
return hmac.compare_digest(expected, received)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def header(headers: Mapping[str, Any]) -> str:
|
|
39
|
+
for name, value in headers.items():
|
|
40
|
+
if str(name).lower() == SIGNATURE_HEADER.lower():
|
|
41
|
+
return str(value)
|
|
42
|
+
return ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def verify_webhook(secret: str, payload: Payload, signature: str) -> WebhookEvent:
|
|
46
|
+
if not secret:
|
|
47
|
+
raise ConfigurationError(text("error.missing_secret"))
|
|
48
|
+
body = _bytes(payload)
|
|
49
|
+
if len(body) > MAX_PAYLOAD:
|
|
50
|
+
raise SignatureError(text("error.payload_size", limit=MAX_PAYLOAD))
|
|
51
|
+
if not matches(secret, body, signature):
|
|
52
|
+
raise SignatureError(text("error.signature"))
|
|
53
|
+
return parse_webhook(body)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_webhook(payload: Payload) -> WebhookEvent:
|
|
57
|
+
try:
|
|
58
|
+
data = json.loads(_bytes(payload))
|
|
59
|
+
except ValueError:
|
|
60
|
+
raise SignatureError(text("error.payload")) from None
|
|
61
|
+
if not isinstance(data, Mapping) or not data:
|
|
62
|
+
raise SignatureError(text("error.payload"))
|
|
63
|
+
return WebhookEvent.from_api(data)
|