robase-utils 2.3.0__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.
- robase_utils-2.3.0.dist-info/METADATA +663 -0
- robase_utils-2.3.0.dist-info/RECORD +40 -0
- robase_utils-2.3.0.dist-info/WHEEL +5 -0
- robase_utils-2.3.0.dist-info/entry_points.txt +2 -0
- robase_utils-2.3.0.dist-info/top_level.txt +1 -0
- roboat_utils/__init__.py +128 -0
- roboat_utils/__main__.py +5 -0
- roboat_utils/analytics.py +343 -0
- roboat_utils/async_client.py +481 -0
- roboat_utils/avatar.py +45 -0
- roboat_utils/badges.py +50 -0
- roboat_utils/catalog.py +81 -0
- roboat_utils/client.py +332 -0
- roboat_utils/database.py +258 -0
- roboat_utils/develop.py +517 -0
- roboat_utils/economy.py +64 -0
- roboat_utils/events.py +259 -0
- roboat_utils/exceptions.py +221 -0
- roboat_utils/friends.py +80 -0
- roboat_utils/games.py +220 -0
- roboat_utils/groups.py +356 -0
- roboat_utils/inventory.py +189 -0
- roboat_utils/marketplace.py +279 -0
- roboat_utils/messages.py +194 -0
- roboat_utils/models.py +520 -0
- roboat_utils/moderation.py +233 -0
- roboat_utils/notifications.py +150 -0
- roboat_utils/oauth.py +152 -0
- roboat_utils/opencloud.py +456 -0
- roboat_utils/presence.py +49 -0
- roboat_utils/publish.py +222 -0
- roboat_utils/session.py +626 -0
- roboat_utils/social.py +240 -0
- roboat_utils/thumbnails.py +94 -0
- roboat_utils/trades.py +213 -0
- roboat_utils/users.py +76 -0
- roboat_utils/utils/__init__.py +5 -0
- roboat_utils/utils/cache.py +152 -0
- roboat_utils/utils/paginator.py +70 -0
- roboat_utils/utils/ratelimit.py +128 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""
|
|
2
|
+
roboat_utils.async_client
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Async version of RoboatClient using aiohttp.
|
|
5
|
+
|
|
6
|
+
Bug fixes vs original:
|
|
7
|
+
- AsyncRoboatClient.__init__ accepted `cookie` param but stored nothing on error
|
|
8
|
+
- _handle used resp.ok which doesn't exist on aiohttp (use resp.status)
|
|
9
|
+
- All sub-API classes referenced undefined `Optional` without import
|
|
10
|
+
- get_servers used BASE (v1) but passed server_type in URL path — fixed
|
|
11
|
+
- get_user_games used BASE2 but the class only defined BASE — added BASE2
|
|
12
|
+
|
|
13
|
+
Requires: pip install aiohttp
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import aiohttp
|
|
23
|
+
_AIOHTTP_AVAILABLE = True
|
|
24
|
+
except ImportError:
|
|
25
|
+
_AIOHTTP_AVAILABLE = False
|
|
26
|
+
|
|
27
|
+
from .models import (
|
|
28
|
+
User, Game, GameVotes, GameServer, Friend, Group, GroupRole,
|
|
29
|
+
Badge, CatalogItem, UserPresence, Avatar, RobuxBalance, Page,
|
|
30
|
+
)
|
|
31
|
+
from .exceptions import (
|
|
32
|
+
RateLimitedError, InvalidCookieError, UserNotFoundError,
|
|
33
|
+
GameNotFoundError, ItemNotFoundError, RoboatAPIError,
|
|
34
|
+
NotAuthenticatedError, raise_for_response,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AsyncRoboatClient:
|
|
39
|
+
"""
|
|
40
|
+
High-performance async Roblox API client using aiohttp.
|
|
41
|
+
|
|
42
|
+
Use as an async context manager::
|
|
43
|
+
|
|
44
|
+
async with AsyncRoboatClient(cookie="...") as client:
|
|
45
|
+
user = await client.users.get_user(156)
|
|
46
|
+
|
|
47
|
+
# Fan-out: fetch 1 000 users in parallel
|
|
48
|
+
user_ids = list(range(1, 1001))
|
|
49
|
+
users = await client.users.get_users_by_ids(user_ids)
|
|
50
|
+
|
|
51
|
+
# Parallel fetch with asyncio.gather
|
|
52
|
+
game, votes, icon = await asyncio.gather(
|
|
53
|
+
client.games.get_game(2753915549),
|
|
54
|
+
client.games.get_votes([2753915549]),
|
|
55
|
+
client.thumbnails.get_game_icons([2753915549]),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
Or manage the session manually::
|
|
59
|
+
|
|
60
|
+
client = AsyncRoboatClient(cookie="...")
|
|
61
|
+
await client.start()
|
|
62
|
+
user = await client.users.get_user(156)
|
|
63
|
+
await client.close()
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
_BASE_HEADERS: Dict[str, str] = {
|
|
67
|
+
"Accept": "application/json",
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
"User-Agent": "roboat-utils/3.0-async",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def __init__(self, cookie: Optional[str] = None, timeout: int = 10) -> None:
|
|
73
|
+
if not _AIOHTTP_AVAILABLE:
|
|
74
|
+
raise ImportError(
|
|
75
|
+
"aiohttp is required for AsyncRoboatClient. "
|
|
76
|
+
"Install it: pip install roboat-utils[async]"
|
|
77
|
+
)
|
|
78
|
+
self._cookie = cookie
|
|
79
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
|
80
|
+
self._csrf_token: Optional[str] = None
|
|
81
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
82
|
+
|
|
83
|
+
self.users = _AsyncUsersAPI(self)
|
|
84
|
+
self.games = _AsyncGamesAPI(self)
|
|
85
|
+
self.friends = _AsyncFriendsAPI(self)
|
|
86
|
+
self.catalog = _AsyncCatalogAPI(self)
|
|
87
|
+
self.groups = _AsyncGroupsAPI(self)
|
|
88
|
+
self.thumbnails = _AsyncThumbnailsAPI(self)
|
|
89
|
+
self.badges = _AsyncBadgesAPI(self)
|
|
90
|
+
self.presence = _AsyncPresenceAPI(self)
|
|
91
|
+
self.economy = _AsyncEconomyAPI(self)
|
|
92
|
+
|
|
93
|
+
# ── Lifecycle ─────────────────────────────────────────────────── #
|
|
94
|
+
|
|
95
|
+
async def start(self) -> None:
|
|
96
|
+
"""Open the aiohttp session."""
|
|
97
|
+
cookies: Dict[str, str] = {}
|
|
98
|
+
if self._cookie:
|
|
99
|
+
cookies[".ROBLOSECURITY"] = self._cookie
|
|
100
|
+
self._session = aiohttp.ClientSession(
|
|
101
|
+
headers=self._BASE_HEADERS,
|
|
102
|
+
cookies=cookies,
|
|
103
|
+
timeout=self._timeout,
|
|
104
|
+
)
|
|
105
|
+
if self._cookie:
|
|
106
|
+
await self._refresh_csrf()
|
|
107
|
+
|
|
108
|
+
async def close(self) -> None:
|
|
109
|
+
"""Close the aiohttp session."""
|
|
110
|
+
if self._session:
|
|
111
|
+
await self._session.close()
|
|
112
|
+
self._session = None
|
|
113
|
+
|
|
114
|
+
async def __aenter__(self) -> "AsyncRoboatClient":
|
|
115
|
+
await self.start()
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
119
|
+
await self.close()
|
|
120
|
+
|
|
121
|
+
# ── Auth ──────────────────────────────────────────────────────── #
|
|
122
|
+
|
|
123
|
+
async def _refresh_csrf(self) -> None:
|
|
124
|
+
try:
|
|
125
|
+
async with self._session.post( # type: ignore[union-attr]
|
|
126
|
+
"https://auth.roblox.com/v2/logout"
|
|
127
|
+
) as r:
|
|
128
|
+
token = r.headers.get("x-csrf-token")
|
|
129
|
+
if token:
|
|
130
|
+
self._csrf_token = token
|
|
131
|
+
self._session.headers.update({"x-csrf-token": token}) # type: ignore[union-attr]
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
def require_auth(self, method: str = "") -> None:
|
|
136
|
+
if not self._cookie:
|
|
137
|
+
raise NotAuthenticatedError(method)
|
|
138
|
+
|
|
139
|
+
# ── HTTP helpers ──────────────────────────────────────────────── #
|
|
140
|
+
|
|
141
|
+
async def _handle(self, resp: "aiohttp.ClientResponse") -> Any:
|
|
142
|
+
if resp.status == 403:
|
|
143
|
+
token = resp.headers.get("x-csrf-token")
|
|
144
|
+
if token and self._session:
|
|
145
|
+
self._csrf_token = token
|
|
146
|
+
self._session.headers.update({"x-csrf-token": token})
|
|
147
|
+
# caller will retry
|
|
148
|
+
return {}
|
|
149
|
+
|
|
150
|
+
if resp.status >= 400:
|
|
151
|
+
try:
|
|
152
|
+
body = await resp.json(content_type=None)
|
|
153
|
+
except Exception:
|
|
154
|
+
body = {}
|
|
155
|
+
raise_for_response(resp.status, body, url=str(resp.url))
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
return await resp.json(content_type=None)
|
|
159
|
+
except Exception:
|
|
160
|
+
return {}
|
|
161
|
+
|
|
162
|
+
async def _get(self, url: str, **kwargs: Any) -> Any:
|
|
163
|
+
async with self._session.get(url, **kwargs) as r: # type: ignore[union-attr]
|
|
164
|
+
return await self._handle(r)
|
|
165
|
+
|
|
166
|
+
async def _post(self, url: str, **kwargs: Any) -> Any:
|
|
167
|
+
async with self._session.post(url, **kwargs) as r: # type: ignore[union-attr]
|
|
168
|
+
result = await self._handle(r)
|
|
169
|
+
if result == {} and r.status == 403:
|
|
170
|
+
async with self._session.post(url, **kwargs) as r2: # type: ignore[union-attr]
|
|
171
|
+
return await self._handle(r2)
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
# ── Convenience shortcuts ─────────────────────────────────────── #
|
|
175
|
+
|
|
176
|
+
async def user_id(self) -> int:
|
|
177
|
+
self.require_auth("user_id")
|
|
178
|
+
data = await self._get("https://users.roblox.com/v1/users/authenticated")
|
|
179
|
+
return data["id"]
|
|
180
|
+
|
|
181
|
+
async def username(self) -> str:
|
|
182
|
+
self.require_auth("username")
|
|
183
|
+
data = await self._get("https://users.roblox.com/v1/users/authenticated")
|
|
184
|
+
return data["name"]
|
|
185
|
+
|
|
186
|
+
async def robux(self) -> int:
|
|
187
|
+
self.require_auth("robux")
|
|
188
|
+
uid = await self.user_id()
|
|
189
|
+
data = await self._get(f"https://economy.roblox.com/v1/users/{uid}/currency")
|
|
190
|
+
return data.get("robux", 0)
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def is_authenticated(self) -> bool:
|
|
194
|
+
return self._cookie is not None
|
|
195
|
+
|
|
196
|
+
def __repr__(self) -> str:
|
|
197
|
+
auth = "authenticated" if self.is_authenticated else "unauthenticated"
|
|
198
|
+
return f"<AsyncRoboatClient [{auth}]>"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ── Sub-API implementations ───────────────────────────────────────── #
|
|
202
|
+
|
|
203
|
+
class _AsyncUsersAPI:
|
|
204
|
+
BASE = "https://users.roblox.com/v1"
|
|
205
|
+
|
|
206
|
+
def __init__(self, c: AsyncRoboatClient) -> None:
|
|
207
|
+
self._c = c
|
|
208
|
+
|
|
209
|
+
async def get_user(self, user_id: int) -> User:
|
|
210
|
+
data = await self._c._get(f"{self.BASE}/users/{user_id}")
|
|
211
|
+
return User.from_dict(data)
|
|
212
|
+
|
|
213
|
+
async def get_users_by_ids(
|
|
214
|
+
self, user_ids: List[int], exclude_banned: bool = False
|
|
215
|
+
) -> List[User]:
|
|
216
|
+
results: List[User] = []
|
|
217
|
+
for i in range(0, len(user_ids), 100):
|
|
218
|
+
chunk = user_ids[i : i + 100]
|
|
219
|
+
data = await self._c._post(
|
|
220
|
+
f"{self.BASE}/users",
|
|
221
|
+
json={"userIds": chunk, "excludeBannedUsers": exclude_banned},
|
|
222
|
+
)
|
|
223
|
+
results.extend(User.from_dict(u) for u in data.get("data", []))
|
|
224
|
+
return results
|
|
225
|
+
|
|
226
|
+
async def get_users_by_usernames(self, usernames: List[str]) -> List[User]:
|
|
227
|
+
data = await self._c._post(
|
|
228
|
+
f"{self.BASE}/usernames/users",
|
|
229
|
+
json={"usernames": usernames, "excludeBannedUsers": False},
|
|
230
|
+
)
|
|
231
|
+
return [User.from_dict(u) for u in data.get("data", [])]
|
|
232
|
+
|
|
233
|
+
async def search_users(self, keyword: str, limit: int = 10) -> Page:
|
|
234
|
+
data = await self._c._get(
|
|
235
|
+
f"{self.BASE}/users/search",
|
|
236
|
+
params={"keyword": keyword, "limit": limit},
|
|
237
|
+
)
|
|
238
|
+
return Page.from_dict(data, User)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class _AsyncGamesAPI:
|
|
242
|
+
BASE = "https://games.roblox.com/v1"
|
|
243
|
+
BASE2 = "https://games.roblox.com/v2"
|
|
244
|
+
|
|
245
|
+
def __init__(self, c: AsyncRoboatClient) -> None:
|
|
246
|
+
self._c = c
|
|
247
|
+
|
|
248
|
+
async def get_games(self, universe_ids: List[int]) -> List[Game]:
|
|
249
|
+
data = await self._c._get(
|
|
250
|
+
f"{self.BASE}/games",
|
|
251
|
+
params={"universeIds": ",".join(str(i) for i in universe_ids)},
|
|
252
|
+
)
|
|
253
|
+
return [Game.from_dict(g) for g in data.get("data", [])]
|
|
254
|
+
|
|
255
|
+
async def get_game(self, universe_id: int) -> Game:
|
|
256
|
+
games = await self.get_games([universe_id])
|
|
257
|
+
if not games:
|
|
258
|
+
raise GameNotFoundError(universe_id)
|
|
259
|
+
return games[0]
|
|
260
|
+
|
|
261
|
+
async def get_votes(self, universe_ids: List[int]) -> List[GameVotes]:
|
|
262
|
+
data = await self._c._get(
|
|
263
|
+
f"{self.BASE}/games/votes",
|
|
264
|
+
params={"universeIds": ",".join(str(i) for i in universe_ids)},
|
|
265
|
+
)
|
|
266
|
+
return [GameVotes.from_dict(v) for v in data.get("data", [])]
|
|
267
|
+
|
|
268
|
+
async def get_servers(
|
|
269
|
+
self, place_id: int, server_type: str = "Public", limit: int = 10
|
|
270
|
+
) -> Page:
|
|
271
|
+
data = await self._c._get(
|
|
272
|
+
f"{self.BASE}/games/{place_id}/servers/{server_type}",
|
|
273
|
+
params={"limit": limit},
|
|
274
|
+
)
|
|
275
|
+
return Page(
|
|
276
|
+
data=[GameServer.from_dict(s) for s in data.get("data", [])],
|
|
277
|
+
next_cursor=data.get("nextPageCursor"),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
async def get_user_games(self, user_id: int, limit: int = 50) -> Page:
|
|
281
|
+
data = await self._c._get(
|
|
282
|
+
f"{self.BASE2}/users/{user_id}/games",
|
|
283
|
+
params={"limit": limit},
|
|
284
|
+
)
|
|
285
|
+
return Page.from_dict(data, Game)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class _AsyncFriendsAPI:
|
|
289
|
+
BASE = "https://friends.roblox.com/v1"
|
|
290
|
+
|
|
291
|
+
def __init__(self, c: AsyncRoboatClient) -> None:
|
|
292
|
+
self._c = c
|
|
293
|
+
|
|
294
|
+
async def get_friends(self, user_id: int) -> List[Friend]:
|
|
295
|
+
data = await self._c._get(f"{self.BASE}/users/{user_id}/friends")
|
|
296
|
+
return [Friend.from_dict(f) for f in data.get("data", [])]
|
|
297
|
+
|
|
298
|
+
async def get_friend_count(self, user_id: int) -> int:
|
|
299
|
+
data = await self._c._get(f"{self.BASE}/users/{user_id}/friends/count")
|
|
300
|
+
return data.get("count", 0)
|
|
301
|
+
|
|
302
|
+
async def get_follower_count(self, user_id: int) -> int:
|
|
303
|
+
data = await self._c._get(f"{self.BASE}/users/{user_id}/followers/count")
|
|
304
|
+
return data.get("count", 0)
|
|
305
|
+
|
|
306
|
+
async def get_followers(
|
|
307
|
+
self, user_id: int, limit: int = 100, cursor: Optional[str] = None
|
|
308
|
+
) -> Page:
|
|
309
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
310
|
+
if cursor:
|
|
311
|
+
params["cursor"] = cursor
|
|
312
|
+
data = await self._c._get(
|
|
313
|
+
f"{self.BASE}/users/{user_id}/followers", params=params
|
|
314
|
+
)
|
|
315
|
+
return Page.from_dict(data, Friend)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class _AsyncCatalogAPI:
|
|
319
|
+
BASE = "https://catalog.roblox.com/v1"
|
|
320
|
+
|
|
321
|
+
def __init__(self, c: AsyncRoboatClient) -> None:
|
|
322
|
+
self._c = c
|
|
323
|
+
|
|
324
|
+
async def search(
|
|
325
|
+
self,
|
|
326
|
+
keyword: str = "",
|
|
327
|
+
category: str = "All",
|
|
328
|
+
limit: int = 30,
|
|
329
|
+
cursor: Optional[str] = None,
|
|
330
|
+
) -> Page:
|
|
331
|
+
params: Dict[str, Any] = {"category": category, "limit": limit}
|
|
332
|
+
if keyword:
|
|
333
|
+
params["keyword"] = keyword
|
|
334
|
+
if cursor:
|
|
335
|
+
params["cursor"] = cursor
|
|
336
|
+
data = await self._c._get(f"{self.BASE}/search/items", params=params)
|
|
337
|
+
return Page.from_dict(data, CatalogItem)
|
|
338
|
+
|
|
339
|
+
async def get_item_details(self, items: List[dict]) -> List[CatalogItem]:
|
|
340
|
+
data = await self._c._post(
|
|
341
|
+
f"{self.BASE}/catalog/items/details", json={"items": items}
|
|
342
|
+
)
|
|
343
|
+
return [CatalogItem.from_dict(i) for i in data.get("data", [])]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class _AsyncGroupsAPI:
|
|
347
|
+
BASE = "https://groups.roblox.com/v1"
|
|
348
|
+
|
|
349
|
+
def __init__(self, c: AsyncRoboatClient) -> None:
|
|
350
|
+
self._c = c
|
|
351
|
+
|
|
352
|
+
async def get_group(self, group_id: int) -> Group:
|
|
353
|
+
data = await self._c._get(f"{self.BASE}/groups/{group_id}")
|
|
354
|
+
return Group.from_dict(data)
|
|
355
|
+
|
|
356
|
+
async def get_roles(self, group_id: int) -> List[GroupRole]:
|
|
357
|
+
data = await self._c._get(f"{self.BASE}/groups/{group_id}/roles")
|
|
358
|
+
return [GroupRole.from_dict(r) for r in data.get("roles", [])]
|
|
359
|
+
|
|
360
|
+
async def get_members(
|
|
361
|
+
self, group_id: int, limit: int = 100, cursor: Optional[str] = None
|
|
362
|
+
) -> Page:
|
|
363
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
364
|
+
if cursor:
|
|
365
|
+
params["cursor"] = cursor
|
|
366
|
+
data = await self._c._get(f"{self.BASE}/groups/{group_id}/users", params=params)
|
|
367
|
+
return Page.from_dict(data)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class _AsyncThumbnailsAPI:
|
|
371
|
+
BASE = "https://thumbnails.roblox.com/v1"
|
|
372
|
+
|
|
373
|
+
def __init__(self, c: AsyncRoboatClient) -> None:
|
|
374
|
+
self._c = c
|
|
375
|
+
|
|
376
|
+
def _urls(self, data: dict) -> Dict[int, str]:
|
|
377
|
+
return {
|
|
378
|
+
item["targetId"]: item.get("imageUrl", "")
|
|
379
|
+
for item in data.get("data", [])
|
|
380
|
+
if item.get("state") == "Completed"
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async def get_user_avatars(
|
|
384
|
+
self, user_ids: List[int], size: str = "420x420"
|
|
385
|
+
) -> Dict[int, str]:
|
|
386
|
+
data = await self._c._get(
|
|
387
|
+
f"{self.BASE}/users/avatar",
|
|
388
|
+
params={
|
|
389
|
+
"userIds": ",".join(str(i) for i in user_ids),
|
|
390
|
+
"size": size,
|
|
391
|
+
"format": "Png",
|
|
392
|
+
},
|
|
393
|
+
)
|
|
394
|
+
return self._urls(data)
|
|
395
|
+
|
|
396
|
+
async def get_game_icons(
|
|
397
|
+
self, universe_ids: List[int], size: str = "512x512"
|
|
398
|
+
) -> Dict[int, str]:
|
|
399
|
+
data = await self._c._get(
|
|
400
|
+
f"{self.BASE}/games/icons",
|
|
401
|
+
params={
|
|
402
|
+
"universeIds": ",".join(str(i) for i in universe_ids),
|
|
403
|
+
"size": size,
|
|
404
|
+
"format": "Png",
|
|
405
|
+
},
|
|
406
|
+
)
|
|
407
|
+
return self._urls(data)
|
|
408
|
+
|
|
409
|
+
async def get_asset_thumbnails(
|
|
410
|
+
self, asset_ids: List[int], size: str = "420x420"
|
|
411
|
+
) -> Dict[int, str]:
|
|
412
|
+
data = await self._c._get(
|
|
413
|
+
f"{self.BASE}/assets",
|
|
414
|
+
params={
|
|
415
|
+
"assetIds": ",".join(str(i) for i in asset_ids),
|
|
416
|
+
"size": size,
|
|
417
|
+
"format": "Png",
|
|
418
|
+
},
|
|
419
|
+
)
|
|
420
|
+
return self._urls(data)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class _AsyncBadgesAPI:
|
|
424
|
+
BASE = "https://badges.roblox.com/v1"
|
|
425
|
+
|
|
426
|
+
def __init__(self, c: AsyncRoboatClient) -> None:
|
|
427
|
+
self._c = c
|
|
428
|
+
|
|
429
|
+
async def get_badge(self, badge_id: int) -> Badge:
|
|
430
|
+
data = await self._c._get(f"{self.BASE}/badges/{badge_id}")
|
|
431
|
+
return Badge.from_dict(data)
|
|
432
|
+
|
|
433
|
+
async def get_universe_badges(self, universe_id: int, limit: int = 10) -> Page:
|
|
434
|
+
data = await self._c._get(
|
|
435
|
+
f"{self.BASE}/universes/{universe_id}/badges", params={"limit": limit}
|
|
436
|
+
)
|
|
437
|
+
return Page.from_dict(data, Badge)
|
|
438
|
+
|
|
439
|
+
async def get_user_badges(self, user_id: int, limit: int = 100) -> Page:
|
|
440
|
+
data = await self._c._get(
|
|
441
|
+
f"{self.BASE}/users/{user_id}/badges", params={"limit": limit}
|
|
442
|
+
)
|
|
443
|
+
return Page.from_dict(data, Badge)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class _AsyncPresenceAPI:
|
|
447
|
+
BASE = "https://presence.roblox.com/v1"
|
|
448
|
+
|
|
449
|
+
def __init__(self, c: AsyncRoboatClient) -> None:
|
|
450
|
+
self._c = c
|
|
451
|
+
|
|
452
|
+
async def get_presences(self, user_ids: List[int]) -> List[UserPresence]:
|
|
453
|
+
data = await self._c._post(
|
|
454
|
+
f"{self.BASE}/presence/users", json={"userIds": user_ids}
|
|
455
|
+
)
|
|
456
|
+
return [UserPresence.from_dict(p) for p in data.get("userPresences", [])]
|
|
457
|
+
|
|
458
|
+
async def get_presence(self, user_id: int) -> UserPresence:
|
|
459
|
+
results = await self.get_presences([user_id])
|
|
460
|
+
return results[0] if results else UserPresence(user_id=user_id)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class _AsyncEconomyAPI:
|
|
464
|
+
BASE = "https://economy.roblox.com/v1"
|
|
465
|
+
|
|
466
|
+
def __init__(self, c: AsyncRoboatClient) -> None:
|
|
467
|
+
self._c = c
|
|
468
|
+
|
|
469
|
+
async def get_robux_balance(self, user_id: int) -> RobuxBalance:
|
|
470
|
+
self._c.require_auth("get_robux_balance")
|
|
471
|
+
data = await self._c._get(f"{self.BASE}/users/{user_id}/currency")
|
|
472
|
+
return RobuxBalance(robux=data.get("robux", 0))
|
|
473
|
+
|
|
474
|
+
async def get_asset_resale_data(self, asset_id: int) -> dict:
|
|
475
|
+
return await self._c._get(f"{self.BASE}/assets/{asset_id}/resale-data")
|
|
476
|
+
|
|
477
|
+
async def get_asset_resellers(self, asset_id: int, limit: int = 10) -> Page:
|
|
478
|
+
data = await self._c._get(
|
|
479
|
+
f"{self.BASE}/assets/{asset_id}/resellers", params={"limit": limit}
|
|
480
|
+
)
|
|
481
|
+
return Page.from_dict(data)
|
roboat_utils/avatar.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
roboat.avatar
|
|
3
|
+
~~~~~~~~~~~~~~~~
|
|
4
|
+
Avatar API — avatar.roblox.com
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from roboat_utils.models import Avatar, Page
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AvatarAPI:
|
|
11
|
+
BASE = "https://avatar.roblox.com/v1"
|
|
12
|
+
BASE2 = "https://avatar.roblox.com/v2"
|
|
13
|
+
|
|
14
|
+
def __init__(self, client):
|
|
15
|
+
self._c = client
|
|
16
|
+
|
|
17
|
+
def get_user_avatar(self, user_id: int) -> Avatar:
|
|
18
|
+
"""Get the full avatar for a user."""
|
|
19
|
+
data = self._c._get(f"{self.BASE}/users/{user_id}/avatar")
|
|
20
|
+
return Avatar.from_dict(user_id, data)
|
|
21
|
+
|
|
22
|
+
def get_authenticated_avatar(self) -> Avatar:
|
|
23
|
+
"""Get the authenticated user's avatar. Requires auth."""
|
|
24
|
+
self._c.require_auth("get_authenticated_avatar")
|
|
25
|
+
uid = self._c.users.get_authenticated_user()["id"]
|
|
26
|
+
data = self._c._get(f"{self.BASE}/avatar")
|
|
27
|
+
return Avatar.from_dict(uid, data)
|
|
28
|
+
|
|
29
|
+
def get_avatar_rules(self) -> dict:
|
|
30
|
+
"""Get all avatar rules (allowed scales, asset types, etc.)."""
|
|
31
|
+
return self._c._get(f"{self.BASE}/avatar-rules")
|
|
32
|
+
|
|
33
|
+
def get_outfit(self, outfit_id: int) -> dict:
|
|
34
|
+
"""Get details for a saved outfit."""
|
|
35
|
+
return self._c._get(f"{self.BASE}/outfits/{outfit_id}/details")
|
|
36
|
+
|
|
37
|
+
def get_user_outfits(self, user_id: int, page: int = 1,
|
|
38
|
+
items_per_page: int = 25,
|
|
39
|
+
is_editable: bool = False) -> dict:
|
|
40
|
+
"""Get outfits for a user."""
|
|
41
|
+
return self._c._get(
|
|
42
|
+
f"{self.BASE}/users/{user_id}/outfits",
|
|
43
|
+
params={"page": page, "itemsPerPage": items_per_page,
|
|
44
|
+
"isEditable": is_editable},
|
|
45
|
+
)
|
roboat_utils/badges.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
roboat.badges
|
|
3
|
+
~~~~~~~~~~~~~~~~
|
|
4
|
+
Badges API — badges.roblox.com
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
from roboat_utils.models import Badge, Page
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BadgesAPI:
|
|
12
|
+
BASE = "https://badges.roblox.com/v1"
|
|
13
|
+
|
|
14
|
+
def __init__(self, client):
|
|
15
|
+
self._c = client
|
|
16
|
+
|
|
17
|
+
def get_badge(self, badge_id: int) -> Badge:
|
|
18
|
+
"""Get badge info by ID."""
|
|
19
|
+
data = self._c._get(f"{self.BASE}/badges/{badge_id}")
|
|
20
|
+
return Badge.from_dict(data)
|
|
21
|
+
|
|
22
|
+
def get_universe_badges(self, universe_id: int, limit: int = 10,
|
|
23
|
+
cursor: Optional[str] = None) -> Page:
|
|
24
|
+
"""Get all badges for a universe/game."""
|
|
25
|
+
params = {"limit": limit, "sortOrder": "Asc"}
|
|
26
|
+
if cursor: params["cursor"] = cursor
|
|
27
|
+
data = self._c._get(f"{self.BASE}/universes/{universe_id}/badges", params=params)
|
|
28
|
+
return Page.from_dict(data, Badge)
|
|
29
|
+
|
|
30
|
+
def get_user_badges(self, user_id: int, limit: int = 10,
|
|
31
|
+
cursor: Optional[str] = None) -> Page:
|
|
32
|
+
"""Get badges awarded to a user."""
|
|
33
|
+
params = {"limit": limit, "sortOrder": "Asc"}
|
|
34
|
+
if cursor: params["cursor"] = cursor
|
|
35
|
+
data = self._c._get(f"{self.BASE}/users/{user_id}/badges", params=params)
|
|
36
|
+
return Page.from_dict(data, Badge)
|
|
37
|
+
|
|
38
|
+
def get_awarded_dates(self, user_id: int, badge_ids: List[int]) -> dict:
|
|
39
|
+
"""
|
|
40
|
+
Check which badges a user has and when they were awarded.
|
|
41
|
+
Returns {badge_id: awarded_date_str}.
|
|
42
|
+
"""
|
|
43
|
+
data = self._c._get(
|
|
44
|
+
f"{self.BASE}/users/{user_id}/badges/awarded-dates",
|
|
45
|
+
params={"badgeIds": ",".join(str(i) for i in badge_ids)},
|
|
46
|
+
)
|
|
47
|
+
return {
|
|
48
|
+
item["badgeId"]: item.get("awardedDate")
|
|
49
|
+
for item in data.get("data", [])
|
|
50
|
+
}
|
roboat_utils/catalog.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
roboat.catalog
|
|
3
|
+
~~~~~~~~~~~~~~~~~
|
|
4
|
+
Catalog API — catalog.roblox.com
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
from roboat_utils.models import CatalogItem, Page
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CatalogAPI:
|
|
12
|
+
BASE = "https://catalog.roblox.com/v1"
|
|
13
|
+
BASE2 = "https://catalog.roblox.com/v2"
|
|
14
|
+
|
|
15
|
+
def __init__(self, client):
|
|
16
|
+
self._c = client
|
|
17
|
+
|
|
18
|
+
def search(self, keyword: str = "", category: str = "All",
|
|
19
|
+
subcategory: str = "All", sort_type: str = "Relevance",
|
|
20
|
+
price_min: Optional[int] = None, price_max: Optional[int] = None,
|
|
21
|
+
limit: int = 30, cursor: Optional[str] = None) -> Page:
|
|
22
|
+
"""Search the avatar catalog."""
|
|
23
|
+
params = {
|
|
24
|
+
"category": category, "subcategory": subcategory,
|
|
25
|
+
"sortType": sort_type, "limit": limit,
|
|
26
|
+
}
|
|
27
|
+
if keyword: params["keyword"] = keyword
|
|
28
|
+
if price_min: params["minPrice"] = price_min
|
|
29
|
+
if price_max: params["maxPrice"] = price_max
|
|
30
|
+
if cursor: params["cursor"] = cursor
|
|
31
|
+
data = self._c._get(f"{self.BASE}/search/items", params=params)
|
|
32
|
+
return Page.from_dict(data, CatalogItem)
|
|
33
|
+
|
|
34
|
+
def get_item_details(self, items: List[dict]) -> List[CatalogItem]:
|
|
35
|
+
"""
|
|
36
|
+
Get details for catalog items.
|
|
37
|
+
items: list of {"itemType": "Asset"|"Bundle", "id": int}
|
|
38
|
+
"""
|
|
39
|
+
data = self._c._post(
|
|
40
|
+
f"{self.BASE}/catalog/items/details",
|
|
41
|
+
json={"items": items},
|
|
42
|
+
)
|
|
43
|
+
return [CatalogItem.from_dict(i) for i in data.get("data", [])]
|
|
44
|
+
|
|
45
|
+
def get_asset(self, asset_id: int) -> CatalogItem:
|
|
46
|
+
"""Get details for a single asset."""
|
|
47
|
+
results = self.get_item_details([{"itemType": "Asset", "id": asset_id}])
|
|
48
|
+
if not results:
|
|
49
|
+
from roboat_utils.exceptions import ItemNotFoundError
|
|
50
|
+
raise ItemNotFoundError(f"Asset {asset_id} not found")
|
|
51
|
+
return results[0]
|
|
52
|
+
|
|
53
|
+
def get_bundle(self, bundle_id: int) -> CatalogItem:
|
|
54
|
+
"""Get details for a single bundle."""
|
|
55
|
+
results = self.get_item_details([{"itemType": "Bundle", "id": bundle_id}])
|
|
56
|
+
if not results:
|
|
57
|
+
from roboat_utils.exceptions import ItemNotFoundError
|
|
58
|
+
raise ItemNotFoundError(f"Bundle {bundle_id} not found")
|
|
59
|
+
return results[0]
|
|
60
|
+
|
|
61
|
+
def get_resale_data(self, asset_id: int):
|
|
62
|
+
"""Get resale history for a limited item. Delegates to economy API."""
|
|
63
|
+
return self._c.economy.get_asset_resale_data(asset_id)
|
|
64
|
+
|
|
65
|
+
def get_resellers(self, asset_id: int, limit: int = 10,
|
|
66
|
+
cursor: Optional[str] = None) -> Page:
|
|
67
|
+
"""Get resellers for a limited item."""
|
|
68
|
+
return self._c.economy.get_asset_resellers(asset_id, limit, cursor)
|
|
69
|
+
|
|
70
|
+
def get_user_bundles(self, user_id: int, limit: int = 10,
|
|
71
|
+
cursor: Optional[str] = None) -> Page:
|
|
72
|
+
"""Get bundles owned by a user."""
|
|
73
|
+
params = {"limit": limit}
|
|
74
|
+
if cursor: params["cursor"] = cursor
|
|
75
|
+
data = self._c._get(f"{self.BASE}/users/{user_id}/bundles", params=params)
|
|
76
|
+
return Page.from_dict(data)
|
|
77
|
+
|
|
78
|
+
def get_asset_favorite_count(self, asset_id: int) -> int:
|
|
79
|
+
"""Get favorite count for an asset."""
|
|
80
|
+
data = self._c._get(f"{self.BASE}/favorites/assets/{asset_id}/count")
|
|
81
|
+
return data if isinstance(data, int) else 0
|