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.
@@ -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)