cloudtips 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {cloudtips-0.1.0 → cloudtips-0.2.0}/PKG-INFO +1 -1
- {cloudtips-0.1.0 → cloudtips-0.2.0}/cloudtips/__init__.py +5 -2
- cloudtips-0.2.0/cloudtips/client.py +309 -0
- cloudtips-0.2.0/cloudtips/models.py +115 -0
- {cloudtips-0.1.0 → cloudtips-0.2.0}/pyproject.toml +1 -1
- cloudtips-0.1.0/cloudtips/client.py +0 -173
- cloudtips-0.1.0/cloudtips/models.py +0 -48
- {cloudtips-0.1.0 → cloudtips-0.2.0}/.github/workflows/publish.yml +0 -0
- {cloudtips-0.1.0 → cloudtips-0.2.0}/LICENSE +0 -0
- {cloudtips-0.1.0 → cloudtips-0.2.0}/README.md +0 -0
- {cloudtips-0.1.0 → cloudtips-0.2.0}/cloudtips/auth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudtips
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Неофициальная Python-библиотека для CloudTips (получение донатов, поллинг, обновление токенов)
|
|
5
5
|
Project-URL: Repository, https://github.com/IRRatium/cloudtips-api
|
|
6
6
|
Project-URL: Issues, https://github.com/IRRatium/cloudtips-api/issues
|
|
@@ -4,14 +4,17 @@ cloudtips — неофициальная Python-библиотека для Clou
|
|
|
4
4
|
|
|
5
5
|
from .auth import CloudTipsAuth, CloudTipsAuthError
|
|
6
6
|
from .client import CloudTipsAPIError, CloudTipsClient
|
|
7
|
-
from .models import Donation, TokenData
|
|
7
|
+
from .models import AccumulationSummary, Card, Donation, PayoutFeeInfo, TokenData
|
|
8
8
|
|
|
9
|
-
__version__ = "0.
|
|
9
|
+
__version__ = "0.2.0"
|
|
10
10
|
__all__ = [
|
|
11
11
|
"CloudTipsAuth",
|
|
12
12
|
"CloudTipsAuthError",
|
|
13
13
|
"CloudTipsClient",
|
|
14
14
|
"CloudTipsAPIError",
|
|
15
15
|
"Donation",
|
|
16
|
+
"Card",
|
|
17
|
+
"PayoutFeeInfo",
|
|
18
|
+
"AccumulationSummary",
|
|
16
19
|
"TokenData",
|
|
17
20
|
]
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Клиент CloudTips API.
|
|
3
|
+
"""
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime, timezone, timedelta
|
|
6
|
+
from typing import Callable, Iterator, List, Optional
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .auth import CloudTipsAuth
|
|
11
|
+
from .models import Donation, Card, PayoutFeeInfo, AccumulationSummary
|
|
12
|
+
|
|
13
|
+
_BASE_URL = "https://api.cloudtips.ru/api"
|
|
14
|
+
_MSK = timezone(timedelta(hours=3))
|
|
15
|
+
|
|
16
|
+
HEADERS_BASE = {
|
|
17
|
+
"Accept": "application/json, text/plain, */*",
|
|
18
|
+
"Accept-Language": "ru-RU,ru;q=0.9",
|
|
19
|
+
"Origin": "https://lk.cloudtips.ru",
|
|
20
|
+
"Referer": "https://lk.cloudtips.ru/",
|
|
21
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CloudTipsClient:
|
|
26
|
+
"""
|
|
27
|
+
Основной клиент для работы с CloudTips API.
|
|
28
|
+
|
|
29
|
+
Пример быстрого старта::
|
|
30
|
+
|
|
31
|
+
from cloudtips import CloudTipsClient, CloudTipsAuth
|
|
32
|
+
|
|
33
|
+
auth = CloudTipsAuth(
|
|
34
|
+
token="...",
|
|
35
|
+
refresh_token="...",
|
|
36
|
+
expires_at=1776099728.0,
|
|
37
|
+
on_token_refresh=lambda td: print("Новый refresh:", td.refresh_token),
|
|
38
|
+
)
|
|
39
|
+
client = CloudTipsClient(auth)
|
|
40
|
+
|
|
41
|
+
donations = client.get_all_donations()
|
|
42
|
+
cards = client.get_cards()
|
|
43
|
+
summary = client.get_accumulation_summary()
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, auth: CloudTipsAuth, base_url: str = _BASE_URL) -> None:
|
|
47
|
+
self._auth = auth
|
|
48
|
+
self._base_url = base_url.rstrip("/")
|
|
49
|
+
self._session = requests.Session()
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
# Донаты
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def get_donations(
|
|
56
|
+
self,
|
|
57
|
+
since: Optional[datetime] = None,
|
|
58
|
+
until: Optional[datetime] = None,
|
|
59
|
+
limit: int = 50,
|
|
60
|
+
page: int = 1,
|
|
61
|
+
) -> List[Donation]:
|
|
62
|
+
"""
|
|
63
|
+
Получить донаты за период через /timeline.
|
|
64
|
+
|
|
65
|
+
:param since: начало периода (по умолчанию — 24 часа назад)
|
|
66
|
+
:param until: конец периода (по умолчанию — сейчас)
|
|
67
|
+
:param limit: кол-во на страницу
|
|
68
|
+
:param page: номер страницы
|
|
69
|
+
:return: список :class:`Donation`
|
|
70
|
+
"""
|
|
71
|
+
now = datetime.now(_MSK)
|
|
72
|
+
date_from = _ensure_tz(since or (now - timedelta(hours=24)))
|
|
73
|
+
date_to = _ensure_tz(until or now)
|
|
74
|
+
|
|
75
|
+
data = self._get("/timeline", params={
|
|
76
|
+
"page": page,
|
|
77
|
+
"limit": limit,
|
|
78
|
+
"dateFrom": date_from.isoformat(),
|
|
79
|
+
"dateTo": date_to.isoformat(),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
raw_items = data.get("data", {}).get("items", [])
|
|
83
|
+
result = []
|
|
84
|
+
for item in raw_items:
|
|
85
|
+
if item.get("operationType") != "Transaction":
|
|
86
|
+
continue
|
|
87
|
+
result.append(Donation.from_dict({
|
|
88
|
+
"transaction_id": item.get("transactionId", 0),
|
|
89
|
+
"name": (item.get("payerName") or "Аноним").strip(),
|
|
90
|
+
"amount": int(item.get("paymentAmount", 0)),
|
|
91
|
+
"tg_id": 0,
|
|
92
|
+
"comment": (item.get("comment") or item.get("payerComment") or "").strip(),
|
|
93
|
+
"date": item.get("createdDate", now.isoformat()),
|
|
94
|
+
}))
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
def get_all_donations(
|
|
98
|
+
self,
|
|
99
|
+
since: Optional[datetime] = None,
|
|
100
|
+
until: Optional[datetime] = None,
|
|
101
|
+
) -> List[Donation]:
|
|
102
|
+
"""
|
|
103
|
+
Получить все донаты за период с автопагинацией.
|
|
104
|
+
|
|
105
|
+
:param since: начало периода
|
|
106
|
+
:param until: конец периода
|
|
107
|
+
:return: полный список :class:`Donation`, отсортированный по дате
|
|
108
|
+
"""
|
|
109
|
+
result: List[Donation] = []
|
|
110
|
+
page = 1
|
|
111
|
+
while True:
|
|
112
|
+
batch = self.get_donations(since=since, until=until, limit=50, page=page)
|
|
113
|
+
if not batch:
|
|
114
|
+
break
|
|
115
|
+
result.extend(batch)
|
|
116
|
+
if len(batch) < 50:
|
|
117
|
+
break
|
|
118
|
+
page += 1
|
|
119
|
+
return sorted(result, key=lambda d: d.date)
|
|
120
|
+
|
|
121
|
+
def poll(
|
|
122
|
+
self,
|
|
123
|
+
interval: int = 30,
|
|
124
|
+
since: Optional[datetime] = None,
|
|
125
|
+
callback: Optional[Callable[[Donation], None]] = None,
|
|
126
|
+
) -> Iterator[Donation]:
|
|
127
|
+
"""
|
|
128
|
+
Генератор: бесконечный поллинг новых донатов.
|
|
129
|
+
|
|
130
|
+
Использование как генератора::
|
|
131
|
+
|
|
132
|
+
for donation in client.poll(interval=15):
|
|
133
|
+
print(f"Новый донат: {donation}")
|
|
134
|
+
|
|
135
|
+
Или с колбэком (блокирующий режим)::
|
|
136
|
+
|
|
137
|
+
client.poll(interval=15, callback=handle_donation)
|
|
138
|
+
|
|
139
|
+
:param interval: пауза между запросами в секундах
|
|
140
|
+
:param since: с какого момента начинать (по умолчанию — прямо сейчас)
|
|
141
|
+
:param callback: если передан — метод блокируется и вызывает колбэк
|
|
142
|
+
"""
|
|
143
|
+
last_seen_ids: set = set()
|
|
144
|
+
cursor = _ensure_tz(since or datetime.now(_MSK))
|
|
145
|
+
|
|
146
|
+
for d in self.get_all_donations(since=cursor):
|
|
147
|
+
last_seen_ids.add(d.transaction_id)
|
|
148
|
+
|
|
149
|
+
while True:
|
|
150
|
+
time.sleep(interval)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
fresh = self.get_all_donations(since=cursor)
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
print(f"[cloudtips] Ошибка при поллинге: {exc}")
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
for donation in fresh:
|
|
159
|
+
if donation.transaction_id not in last_seen_ids:
|
|
160
|
+
last_seen_ids.add(donation.transaction_id)
|
|
161
|
+
cursor = max(cursor, donation.date)
|
|
162
|
+
if callback:
|
|
163
|
+
callback(donation)
|
|
164
|
+
else:
|
|
165
|
+
yield donation
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
# Карты
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def get_cards(self) -> List[Card]:
|
|
172
|
+
"""
|
|
173
|
+
Получить список привязанных карт.
|
|
174
|
+
|
|
175
|
+
:return: список :class:`Card`
|
|
176
|
+
|
|
177
|
+
Пример::
|
|
178
|
+
|
|
179
|
+
for card in client.get_cards():
|
|
180
|
+
print(card) # MIR *3742 (T-BANK (TINKOFF), до 08/34) [по умолчанию]
|
|
181
|
+
print(card.token) # tk_89e6b3c6827afd4e9ccc36db2d22f
|
|
182
|
+
"""
|
|
183
|
+
data = self._get("/cards")
|
|
184
|
+
return [Card.from_dict(item) for item in data.get("data", [])]
|
|
185
|
+
|
|
186
|
+
def delete_card(self, card_token: str) -> bool:
|
|
187
|
+
"""
|
|
188
|
+
Удалить привязанную карту.
|
|
189
|
+
|
|
190
|
+
:param card_token: токен карты (``card.token``)
|
|
191
|
+
:return: ``True`` если удаление прошло успешно
|
|
192
|
+
|
|
193
|
+
Пример::
|
|
194
|
+
|
|
195
|
+
for card in client.get_cards():
|
|
196
|
+
client.delete_card(card.token)
|
|
197
|
+
"""
|
|
198
|
+
data = self._delete("/cards", json={"cardToken": card_token})
|
|
199
|
+
return data.get("succeed", False)
|
|
200
|
+
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
# Выплаты и баланс
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
def get_payout_fee_info(self) -> PayoutFeeInfo:
|
|
206
|
+
"""
|
|
207
|
+
Получить информацию о комиссиях при выводе средств.
|
|
208
|
+
|
|
209
|
+
:return: :class:`PayoutFeeInfo`
|
|
210
|
+
|
|
211
|
+
Пример::
|
|
212
|
+
|
|
213
|
+
fee = client.get_payout_fee_info()
|
|
214
|
+
print(fee.text)
|
|
215
|
+
# Стоимость вывода денег на карты Т-Банка — 5%
|
|
216
|
+
# Стоимость вывода денег на карты других банков — 7%*
|
|
217
|
+
"""
|
|
218
|
+
data = self._get("/payout/fee/info")
|
|
219
|
+
return PayoutFeeInfo.from_dict(data.get("data", {}))
|
|
220
|
+
|
|
221
|
+
def get_accumulation_summary(self) -> AccumulationSummary:
|
|
222
|
+
"""
|
|
223
|
+
Получить сводку по накопленным средствам (баланс к выводу).
|
|
224
|
+
|
|
225
|
+
:return: :class:`AccumulationSummary`
|
|
226
|
+
|
|
227
|
+
Пример::
|
|
228
|
+
|
|
229
|
+
s = client.get_accumulation_summary()
|
|
230
|
+
print(f"Накоплено: {s.accumulated_amount}₽")
|
|
231
|
+
print(f"Комиссия: {s.commission_percent}%")
|
|
232
|
+
print(f"Следующая выплата: {s.next_payout_date or 'не запланирована'}")
|
|
233
|
+
"""
|
|
234
|
+
data = self._get("/accumulations/summary")
|
|
235
|
+
return AccumulationSummary.from_dict(data.get("data", {}))
|
|
236
|
+
|
|
237
|
+
def set_payout_method(self, method: str = "Instant") -> bool:
|
|
238
|
+
"""
|
|
239
|
+
Установить метод выплат.
|
|
240
|
+
|
|
241
|
+
:param method: ``"Instant"`` (мгновенно) или ``"Accumulation"`` (накопительно)
|
|
242
|
+
:return: ``True`` если успешно
|
|
243
|
+
|
|
244
|
+
Пример::
|
|
245
|
+
|
|
246
|
+
client.set_payout_method("Instant")
|
|
247
|
+
"""
|
|
248
|
+
data = self._put("/receivers/payout-method", json={"payoutMethod": method})
|
|
249
|
+
return data.get("succeed", False)
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
# Внутренние HTTP-методы
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
def _get(self, path: str, params: Optional[dict] = None) -> dict:
|
|
256
|
+
headers = {**HEADERS_BASE, **self._auth.headers()}
|
|
257
|
+
response = self._session.get(
|
|
258
|
+
self._base_url + path,
|
|
259
|
+
headers=headers,
|
|
260
|
+
params=params,
|
|
261
|
+
timeout=15,
|
|
262
|
+
)
|
|
263
|
+
_raise_for_status(response)
|
|
264
|
+
return response.json()
|
|
265
|
+
|
|
266
|
+
def _delete(self, path: str, json: Optional[dict] = None) -> dict:
|
|
267
|
+
headers = {**HEADERS_BASE, **self._auth.headers()}
|
|
268
|
+
response = self._session.delete(
|
|
269
|
+
self._base_url + path,
|
|
270
|
+
headers=headers,
|
|
271
|
+
json=json,
|
|
272
|
+
timeout=15,
|
|
273
|
+
)
|
|
274
|
+
_raise_for_status(response)
|
|
275
|
+
return response.json()
|
|
276
|
+
|
|
277
|
+
def _put(self, path: str, json: Optional[dict] = None) -> dict:
|
|
278
|
+
headers = {**HEADERS_BASE, **self._auth.headers()}
|
|
279
|
+
response = self._session.put(
|
|
280
|
+
self._base_url + path,
|
|
281
|
+
headers=headers,
|
|
282
|
+
json=json,
|
|
283
|
+
timeout=15,
|
|
284
|
+
)
|
|
285
|
+
_raise_for_status(response)
|
|
286
|
+
return response.json()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class CloudTipsAPIError(Exception):
|
|
290
|
+
"""Ошибка запроса к CloudTips API."""
|
|
291
|
+
|
|
292
|
+
def __init__(self, status_code: int, detail: object) -> None:
|
|
293
|
+
self.status_code = status_code
|
|
294
|
+
self.detail = detail
|
|
295
|
+
super().__init__(f"HTTP {status_code}: {detail}")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _ensure_tz(dt: datetime) -> datetime:
|
|
299
|
+
"""Добавляет МСК таймзону если её нет."""
|
|
300
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=_MSK)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _raise_for_status(response: requests.Response) -> None:
|
|
304
|
+
if not response.ok:
|
|
305
|
+
try:
|
|
306
|
+
detail = response.json()
|
|
307
|
+
except Exception:
|
|
308
|
+
detail = response.text
|
|
309
|
+
raise CloudTipsAPIError(response.status_code, detail)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Donation:
|
|
8
|
+
transaction_id: int
|
|
9
|
+
name: str
|
|
10
|
+
amount: int # рубли
|
|
11
|
+
tg_id: int
|
|
12
|
+
comment: str
|
|
13
|
+
date: datetime
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_dict(cls, data: dict) -> "Donation":
|
|
17
|
+
raw_date = data["date"]
|
|
18
|
+
try:
|
|
19
|
+
dt = datetime.fromisoformat(raw_date)
|
|
20
|
+
except ValueError:
|
|
21
|
+
dt = datetime.fromisoformat(raw_date[:19])
|
|
22
|
+
|
|
23
|
+
return cls(
|
|
24
|
+
transaction_id=data["transaction_id"],
|
|
25
|
+
name=data.get("name", ""),
|
|
26
|
+
amount=data["amount"],
|
|
27
|
+
tg_id=data.get("tg_id", 0),
|
|
28
|
+
comment=data.get("comment", ""),
|
|
29
|
+
date=dt,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
comment_part = f' — "{self.comment}"' if self.comment else ""
|
|
34
|
+
return (
|
|
35
|
+
f"[{self.date.strftime('%Y-%m-%d %H:%M')}] "
|
|
36
|
+
f"{self.name} → {self.amount}₽{comment_part}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Card:
|
|
42
|
+
token: str # токен для удаления и операций
|
|
43
|
+
first_six: str # первые 6 цифр (BIN)
|
|
44
|
+
last_four: str # последние 4 цифры
|
|
45
|
+
card_type: str # MIR / VISA / MASTERCARD
|
|
46
|
+
expiration_date: str # MM/YY
|
|
47
|
+
issuer_code: str # название банка
|
|
48
|
+
is_default: bool # карта по умолчанию для выплат
|
|
49
|
+
commission_hint: str # текст подсказки о комиссии
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, data: dict) -> "Card":
|
|
53
|
+
return cls(
|
|
54
|
+
token=data["token"],
|
|
55
|
+
first_six=data.get("firstSix", ""),
|
|
56
|
+
last_four=data.get("lastFour", ""),
|
|
57
|
+
card_type=data.get("cardType", ""),
|
|
58
|
+
expiration_date=data.get("cardExpirationDate", ""),
|
|
59
|
+
issuer_code=data.get("issuerCode", ""),
|
|
60
|
+
is_default=data.get("isDefault", False),
|
|
61
|
+
commission_hint=data.get("commissionHint", ""),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def __str__(self) -> str:
|
|
65
|
+
default = " [по умолчанию]" if self.is_default else ""
|
|
66
|
+
return (
|
|
67
|
+
f"{self.card_type} *{self.last_four} "
|
|
68
|
+
f"({self.issuer_code}, до {self.expiration_date}){default}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class PayoutFeeInfo:
|
|
74
|
+
text: str # текст с описанием комиссий
|
|
75
|
+
downgrade_condition: str
|
|
76
|
+
tinkoff_commission_hint: str
|
|
77
|
+
instant_payout_commission_text: str
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, data: dict) -> "PayoutFeeInfo":
|
|
81
|
+
return cls(
|
|
82
|
+
text=data.get("text", ""),
|
|
83
|
+
downgrade_condition=data.get("downGradeCondition", ""),
|
|
84
|
+
tinkoff_commission_hint=data.get("tinkoffCommissionHint", ""),
|
|
85
|
+
instant_payout_commission_text=data.get("instantPayoutCommissionText", ""),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class AccumulationSummary:
|
|
91
|
+
accumulated_amount: float # накоплено (ещё не выведено)
|
|
92
|
+
amount_to_deposit: float # к зачислению
|
|
93
|
+
commission: float # сумма комиссии
|
|
94
|
+
commission_percent: float # процент комиссии
|
|
95
|
+
next_payout_date: Optional[str] # дата следующей выплаты (None если не запланирована)
|
|
96
|
+
commission_hint: str # текст подсказки
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_dict(cls, data: dict) -> "AccumulationSummary":
|
|
100
|
+
return cls(
|
|
101
|
+
accumulated_amount=data.get("accumulatedAmount", 0.0),
|
|
102
|
+
amount_to_deposit=data.get("amountToDeposit", 0.0),
|
|
103
|
+
commission=data.get("commission", 0.0),
|
|
104
|
+
commission_percent=data.get("commissionPercent", 0.0),
|
|
105
|
+
next_payout_date=data.get("nextPayoutDate"),
|
|
106
|
+
commission_hint=data.get("commissionHint", ""),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class TokenData:
|
|
112
|
+
"""Новые токены, которые библиотека передаёт в on_token_refresh."""
|
|
113
|
+
access_token: str
|
|
114
|
+
refresh_token: str
|
|
115
|
+
expires_at: float # unix timestamp
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cloudtips"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Неофициальная Python-библиотека для CloudTips (получение донатов, поллинг, обновление токенов)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Клиент CloudTips API.
|
|
3
|
-
"""
|
|
4
|
-
import time
|
|
5
|
-
from datetime import datetime, timezone
|
|
6
|
-
from typing import Callable, Iterator, List, Optional
|
|
7
|
-
|
|
8
|
-
import requests
|
|
9
|
-
|
|
10
|
-
from .auth import CloudTipsAuth
|
|
11
|
-
from .models import Donation
|
|
12
|
-
|
|
13
|
-
_BASE_URL = "https://api.cloudtips.ru"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class CloudTipsClient:
|
|
17
|
-
"""
|
|
18
|
-
Основной клиент для работы с CloudTips API.
|
|
19
|
-
|
|
20
|
-
Пример быстрого старта::
|
|
21
|
-
|
|
22
|
-
from cloudtips import CloudTipsClient, CloudTipsAuth
|
|
23
|
-
|
|
24
|
-
auth = CloudTipsAuth(
|
|
25
|
-
token="...",
|
|
26
|
-
refresh_token="...",
|
|
27
|
-
expires_at=1776099728.0,
|
|
28
|
-
on_token_refresh=lambda td: print("Новый токен:", td.access_token),
|
|
29
|
-
)
|
|
30
|
-
client = CloudTipsClient(auth)
|
|
31
|
-
|
|
32
|
-
# Получить все донаты
|
|
33
|
-
donations = client.get_donations()
|
|
34
|
-
|
|
35
|
-
# Поллинг новых донатов каждые 30 секунд
|
|
36
|
-
for donation in client.poll(interval=30):
|
|
37
|
-
print(donation)
|
|
38
|
-
"""
|
|
39
|
-
|
|
40
|
-
def __init__(self, auth: CloudTipsAuth, base_url: str = _BASE_URL) -> None:
|
|
41
|
-
self._auth = auth
|
|
42
|
-
self._base_url = base_url.rstrip("/")
|
|
43
|
-
self._session = requests.Session()
|
|
44
|
-
|
|
45
|
-
# ------------------------------------------------------------------
|
|
46
|
-
# Основные методы
|
|
47
|
-
# ------------------------------------------------------------------
|
|
48
|
-
|
|
49
|
-
def get_donations(
|
|
50
|
-
self,
|
|
51
|
-
since: Optional[datetime] = None,
|
|
52
|
-
limit: int = 100,
|
|
53
|
-
page: int = 1,
|
|
54
|
-
) -> List[Donation]:
|
|
55
|
-
"""
|
|
56
|
-
Получить список донатов.
|
|
57
|
-
|
|
58
|
-
:param since: фильтр — только донаты после этого момента
|
|
59
|
-
:param limit: кол-во на страницу (max обычно 100)
|
|
60
|
-
:param page: номер страницы
|
|
61
|
-
:return: список :class:`Donation`
|
|
62
|
-
"""
|
|
63
|
-
params: dict = {"pageSize": limit, "page": page}
|
|
64
|
-
if since is not None:
|
|
65
|
-
params["from"] = since.isoformat()
|
|
66
|
-
|
|
67
|
-
data = self._get("/api/payments", params=params)
|
|
68
|
-
|
|
69
|
-
items = data if isinstance(data, list) else data.get("items", data.get("payments", []))
|
|
70
|
-
return [Donation.from_dict(item) for item in items]
|
|
71
|
-
|
|
72
|
-
def get_all_donations(self, since: Optional[datetime] = None) -> List[Donation]:
|
|
73
|
-
"""
|
|
74
|
-
Получить все донаты с автопагинацией.
|
|
75
|
-
|
|
76
|
-
:param since: фильтр по дате
|
|
77
|
-
:return: полный список :class:`Donation`
|
|
78
|
-
"""
|
|
79
|
-
result: List[Donation] = []
|
|
80
|
-
page = 1
|
|
81
|
-
while True:
|
|
82
|
-
batch = self.get_donations(since=since, limit=100, page=page)
|
|
83
|
-
if not batch:
|
|
84
|
-
break
|
|
85
|
-
result.extend(batch)
|
|
86
|
-
if len(batch) < 100:
|
|
87
|
-
break
|
|
88
|
-
page += 1
|
|
89
|
-
return sorted(result, key=lambda d: d.date)
|
|
90
|
-
|
|
91
|
-
def poll(
|
|
92
|
-
self,
|
|
93
|
-
interval: int = 30,
|
|
94
|
-
since: Optional[datetime] = None,
|
|
95
|
-
callback: Optional[Callable[[Donation], None]] = None,
|
|
96
|
-
) -> Iterator[Donation]:
|
|
97
|
-
"""
|
|
98
|
-
Генератор: бесконечный поллинг новых донатов.
|
|
99
|
-
|
|
100
|
-
Каждые ``interval`` секунд опрашивает API и отдаёт только **новые**
|
|
101
|
-
донаты (те, что появились после последнего запроса).
|
|
102
|
-
|
|
103
|
-
Использование как генератора::
|
|
104
|
-
|
|
105
|
-
for donation in client.poll(interval=15):
|
|
106
|
-
print(f"Новый донат: {donation}")
|
|
107
|
-
|
|
108
|
-
Или с колбэком (блокирующий режим)::
|
|
109
|
-
|
|
110
|
-
client.poll(interval=15, callback=handle_donation)
|
|
111
|
-
|
|
112
|
-
:param interval: пауза между запросами в секундах
|
|
113
|
-
:param since: с какого момента начинать (по умолчанию — «прямо сейчас»)
|
|
114
|
-
:param callback: если передан — метод блокируется и вызывает колбэк
|
|
115
|
-
"""
|
|
116
|
-
last_seen_ids: set = set()
|
|
117
|
-
cursor = since or datetime.now(tz=timezone.utc)
|
|
118
|
-
|
|
119
|
-
# Первый запрос — грузим уже известные, не отдаём как "новые"
|
|
120
|
-
for d in self.get_all_donations(since=cursor):
|
|
121
|
-
last_seen_ids.add(d.transaction_id)
|
|
122
|
-
|
|
123
|
-
while True:
|
|
124
|
-
time.sleep(interval)
|
|
125
|
-
|
|
126
|
-
try:
|
|
127
|
-
fresh = self.get_all_donations(since=cursor)
|
|
128
|
-
except Exception as exc:
|
|
129
|
-
# Не падаем, просто пробуем снова на следующей итерации
|
|
130
|
-
print(f"[cloudtips] Ошибка при поллинге: {exc}")
|
|
131
|
-
continue
|
|
132
|
-
|
|
133
|
-
for donation in fresh:
|
|
134
|
-
if donation.transaction_id not in last_seen_ids:
|
|
135
|
-
last_seen_ids.add(donation.transaction_id)
|
|
136
|
-
cursor = max(cursor, donation.date)
|
|
137
|
-
if callback:
|
|
138
|
-
callback(donation)
|
|
139
|
-
else:
|
|
140
|
-
yield donation
|
|
141
|
-
|
|
142
|
-
# ------------------------------------------------------------------
|
|
143
|
-
# Вспомогательные
|
|
144
|
-
# ------------------------------------------------------------------
|
|
145
|
-
|
|
146
|
-
def _get(self, path: str, params: Optional[dict] = None) -> dict:
|
|
147
|
-
url = self._base_url + path
|
|
148
|
-
response = self._session.get(
|
|
149
|
-
url,
|
|
150
|
-
headers=self._auth.headers(),
|
|
151
|
-
params=params,
|
|
152
|
-
timeout=15,
|
|
153
|
-
)
|
|
154
|
-
_raise_for_status(response)
|
|
155
|
-
return response.json()
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
class CloudTipsAPIError(Exception):
|
|
159
|
-
"""Ошибка запроса к CloudTips API."""
|
|
160
|
-
|
|
161
|
-
def __init__(self, status_code: int, detail: object) -> None:
|
|
162
|
-
self.status_code = status_code
|
|
163
|
-
self.detail = detail
|
|
164
|
-
super().__init__(f"HTTP {status_code}: {detail}")
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def _raise_for_status(response: requests.Response) -> None:
|
|
168
|
-
if not response.ok:
|
|
169
|
-
try:
|
|
170
|
-
detail = response.json()
|
|
171
|
-
except Exception:
|
|
172
|
-
detail = response.text
|
|
173
|
-
raise CloudTipsAPIError(response.status_code, detail)
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
from dataclasses import dataclass, field
|
|
2
|
-
from datetime import datetime
|
|
3
|
-
from typing import Optional
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
@dataclass
|
|
7
|
-
class Donation:
|
|
8
|
-
transaction_id: int
|
|
9
|
-
name: str
|
|
10
|
-
amount: int # рубли
|
|
11
|
-
tg_id: int
|
|
12
|
-
comment: str
|
|
13
|
-
date: datetime
|
|
14
|
-
|
|
15
|
-
@classmethod
|
|
16
|
-
def from_dict(cls, data: dict) -> "Donation":
|
|
17
|
-
raw_date = data["date"]
|
|
18
|
-
# Убираем offset вида +03:00 → datetime aware через fromisoformat (3.11+)
|
|
19
|
-
# или ручной парсинг для 3.9/3.10
|
|
20
|
-
try:
|
|
21
|
-
dt = datetime.fromisoformat(raw_date)
|
|
22
|
-
except ValueError:
|
|
23
|
-
# fallback: обрезаем offset
|
|
24
|
-
dt = datetime.fromisoformat(raw_date[:19])
|
|
25
|
-
|
|
26
|
-
return cls(
|
|
27
|
-
transaction_id=data["transaction_id"],
|
|
28
|
-
name=data.get("name", ""),
|
|
29
|
-
amount=data["amount"],
|
|
30
|
-
tg_id=data.get("tg_id", 0),
|
|
31
|
-
comment=data.get("comment", ""),
|
|
32
|
-
date=dt,
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
def __str__(self) -> str:
|
|
36
|
-
comment_part = f' — "{self.comment}"' if self.comment else ""
|
|
37
|
-
return (
|
|
38
|
-
f"[{self.date.strftime('%Y-%m-%d %H:%M')}] "
|
|
39
|
-
f"{self.name} → {self.amount}₽{comment_part}"
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@dataclass
|
|
44
|
-
class TokenData:
|
|
45
|
-
"""Новые токены, которые библиотека передаёт в on_token_refresh."""
|
|
46
|
-
access_token: str
|
|
47
|
-
refresh_token: str
|
|
48
|
-
expires_at: float # unix timestamp
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|