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
roboat_utils/events.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""
|
|
2
|
+
roboat.events
|
|
3
|
+
~~~~~~~~~~~~~~~~
|
|
4
|
+
Polling-based event system.
|
|
5
|
+
Monitor friends coming online, new game visits, friend requests, etc.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
from roboat import RoboatClient
|
|
10
|
+
from roboat_utils.events import EventPoller
|
|
11
|
+
|
|
12
|
+
client = RoboatClient(cookie="...")
|
|
13
|
+
poller = EventPoller(client)
|
|
14
|
+
|
|
15
|
+
@poller.on_friend_online
|
|
16
|
+
def handle(user):
|
|
17
|
+
print(f"{user.name} just came online!")
|
|
18
|
+
|
|
19
|
+
@poller.on_visit_milestone
|
|
20
|
+
def milestone(game, count):
|
|
21
|
+
print(f"{game.name} just hit {count:,} visits!")
|
|
22
|
+
|
|
23
|
+
poller.start(interval=30) # poll every 30 seconds
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from typing import Callable, Dict, List, Optional, Set
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EventPoller:
|
|
33
|
+
"""
|
|
34
|
+
Background polling engine that fires callbacks when Roblox state changes.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
client: An authenticated RoboatClient.
|
|
38
|
+
interval: Polling interval in seconds (default: 30).
|
|
39
|
+
|
|
40
|
+
Registering handlers::
|
|
41
|
+
|
|
42
|
+
@poller.on("friend_online")
|
|
43
|
+
def handler(user):
|
|
44
|
+
print(user.name, "is online!")
|
|
45
|
+
|
|
46
|
+
Starting / stopping::
|
|
47
|
+
|
|
48
|
+
poller.start() # background thread
|
|
49
|
+
poller.stop()
|
|
50
|
+
poller.run_once() # manual single poll
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, client, interval: float = 30.0):
|
|
54
|
+
self._c = client
|
|
55
|
+
self.interval = interval
|
|
56
|
+
self._handlers: Dict[str, List[Callable]] = {}
|
|
57
|
+
self._thread: Optional[threading.Thread] = None
|
|
58
|
+
self._stop_event = threading.Event()
|
|
59
|
+
|
|
60
|
+
# State tracking
|
|
61
|
+
self._known_friends: Set[int] = set()
|
|
62
|
+
self._known_online: Set[int] = set()
|
|
63
|
+
self._tracked_universes: Dict[int, int] = {} # universe_id → last visit count
|
|
64
|
+
self._visit_milestones: Dict[int, int] = {} # universe_id → next milestone
|
|
65
|
+
self._known_requests: Set[int] = set()
|
|
66
|
+
self._last_message_count: int = -1
|
|
67
|
+
|
|
68
|
+
# ── Registration decorators ───────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
def on(self, event: str) -> Callable:
|
|
71
|
+
"""Generic decorator: @poller.on("event_name")"""
|
|
72
|
+
def decorator(fn: Callable) -> Callable:
|
|
73
|
+
self._handlers.setdefault(event, []).append(fn)
|
|
74
|
+
return fn
|
|
75
|
+
return decorator
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def on_friend_online(self) -> Callable:
|
|
79
|
+
"""Fires when a friend comes online. Handler receives (User,)."""
|
|
80
|
+
return self.on("friend_online")
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def on_friend_offline(self) -> Callable:
|
|
84
|
+
"""Fires when a friend goes offline. Handler receives (User,)."""
|
|
85
|
+
return self.on("friend_offline")
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def on_new_friend(self) -> Callable:
|
|
89
|
+
"""Fires when you gain a new friend. Handler receives (User,)."""
|
|
90
|
+
return self.on("new_friend")
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def on_friend_removed(self) -> Callable:
|
|
94
|
+
"""Fires when a friend is removed. Handler receives (user_id,)."""
|
|
95
|
+
return self.on("friend_removed")
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def on_friend_request(self) -> Callable:
|
|
99
|
+
"""Fires on new friend request. Handler receives (User,)."""
|
|
100
|
+
return self.on("friend_request")
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def on_new_message(self) -> Callable:
|
|
104
|
+
"""Fires when an unread message count increases. Handler receives (count,)."""
|
|
105
|
+
return self.on("new_message")
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def on_visit_milestone(self) -> Callable:
|
|
109
|
+
"""
|
|
110
|
+
Fires when a tracked game hits a visit milestone.
|
|
111
|
+
Handler receives (Game, milestone_count).
|
|
112
|
+
"""
|
|
113
|
+
return self.on("visit_milestone")
|
|
114
|
+
|
|
115
|
+
# ── Game tracking ─────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def track_game(self, universe_id: int,
|
|
118
|
+
milestone_step: int = 1_000_000) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Add a universe to visit monitoring.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
universe_id: The universe to watch.
|
|
124
|
+
milestone_step: Fire an event every N visits (default: 1M).
|
|
125
|
+
"""
|
|
126
|
+
self._tracked_universes[universe_id] = 0
|
|
127
|
+
self._visit_milestones[universe_id] = milestone_step
|
|
128
|
+
|
|
129
|
+
def untrack_game(self, universe_id: int) -> None:
|
|
130
|
+
"""Stop watching a universe for visit milestones."""
|
|
131
|
+
self._tracked_universes.pop(universe_id, None)
|
|
132
|
+
self._visit_milestones.pop(universe_id, None)
|
|
133
|
+
|
|
134
|
+
# ── Control ───────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
def start(self, interval: Optional[float] = None, daemon: bool = True) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Start polling in a background thread.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
interval: Override polling interval.
|
|
142
|
+
daemon: If True, thread exits when main program exits.
|
|
143
|
+
"""
|
|
144
|
+
if interval is not None:
|
|
145
|
+
self.interval = interval
|
|
146
|
+
self._stop_event.clear()
|
|
147
|
+
self._thread = threading.Thread(target=self._loop, daemon=daemon)
|
|
148
|
+
self._thread.start()
|
|
149
|
+
|
|
150
|
+
def stop(self) -> None:
|
|
151
|
+
"""Stop the background polling thread."""
|
|
152
|
+
self._stop_event.set()
|
|
153
|
+
if self._thread:
|
|
154
|
+
self._thread.join(timeout=5)
|
|
155
|
+
self._thread = None
|
|
156
|
+
|
|
157
|
+
def run_once(self) -> None:
|
|
158
|
+
"""Run a single poll cycle (useful for testing or manual control)."""
|
|
159
|
+
self._poll()
|
|
160
|
+
|
|
161
|
+
def _loop(self) -> None:
|
|
162
|
+
while not self._stop_event.is_set():
|
|
163
|
+
try:
|
|
164
|
+
self._poll()
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
self._stop_event.wait(self.interval)
|
|
168
|
+
|
|
169
|
+
# ── Poll logic ────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
def _fire(self, event: str, *args) -> None:
|
|
172
|
+
for handler in self._handlers.get(event, []):
|
|
173
|
+
try:
|
|
174
|
+
handler(*args)
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
def _poll(self) -> None:
|
|
179
|
+
self._poll_friends()
|
|
180
|
+
self._poll_games()
|
|
181
|
+
self._poll_messages()
|
|
182
|
+
|
|
183
|
+
def _poll_friends(self) -> None:
|
|
184
|
+
if not self._c.is_authenticated:
|
|
185
|
+
return
|
|
186
|
+
try:
|
|
187
|
+
uid = self._c.user_id()
|
|
188
|
+
friends = self._c.friends.get_friends(uid)
|
|
189
|
+
current_ids = {f.id for f in friends}
|
|
190
|
+
|
|
191
|
+
# New friends
|
|
192
|
+
for f in friends:
|
|
193
|
+
if f.id not in self._known_friends and self._known_friends:
|
|
194
|
+
self._fire("new_friend", f)
|
|
195
|
+
|
|
196
|
+
# Removed friends
|
|
197
|
+
for fid in (self._known_friends - current_ids):
|
|
198
|
+
self._fire("friend_removed", fid)
|
|
199
|
+
|
|
200
|
+
self._known_friends = current_ids
|
|
201
|
+
|
|
202
|
+
# Online status
|
|
203
|
+
if friends:
|
|
204
|
+
presences = self._c.presence.get_presences(list(current_ids)[:50])
|
|
205
|
+
current_online = {
|
|
206
|
+
p.user_id for p in presences if p.user_presence_type > 0
|
|
207
|
+
}
|
|
208
|
+
friend_map = {f.id: f for f in friends}
|
|
209
|
+
|
|
210
|
+
for uid_p in (current_online - self._known_online):
|
|
211
|
+
if uid_p in friend_map:
|
|
212
|
+
self._fire("friend_online", friend_map[uid_p])
|
|
213
|
+
|
|
214
|
+
for uid_p in (self._known_online - current_online):
|
|
215
|
+
if uid_p in friend_map:
|
|
216
|
+
self._fire("friend_offline", friend_map[uid_p])
|
|
217
|
+
|
|
218
|
+
self._known_online = current_online
|
|
219
|
+
|
|
220
|
+
# Friend requests
|
|
221
|
+
requests_page = self._c.friends.get_friend_requests(limit=10)
|
|
222
|
+
current_requests = {u.id for u in requests_page.data}
|
|
223
|
+
for user in requests_page.data:
|
|
224
|
+
if user.id not in self._known_requests and self._known_requests:
|
|
225
|
+
self._fire("friend_request", user)
|
|
226
|
+
self._known_requests = current_requests
|
|
227
|
+
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
def _poll_games(self) -> None:
|
|
232
|
+
if not self._tracked_universes:
|
|
233
|
+
return
|
|
234
|
+
try:
|
|
235
|
+
ids = list(self._tracked_universes.keys())
|
|
236
|
+
games = self._c.games.get_games(ids)
|
|
237
|
+
for game in games:
|
|
238
|
+
last = self._tracked_universes.get(game.id, 0)
|
|
239
|
+
step = self._visit_milestones.get(game.id, 1_000_000)
|
|
240
|
+
if last == 0:
|
|
241
|
+
self._tracked_universes[game.id] = game.visits
|
|
242
|
+
continue
|
|
243
|
+
next_milestone = (last // step + 1) * step
|
|
244
|
+
if game.visits >= next_milestone:
|
|
245
|
+
self._fire("visit_milestone", game, next_milestone)
|
|
246
|
+
self._tracked_universes[game.id] = game.visits
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
def _poll_messages(self) -> None:
|
|
251
|
+
if not self._c.is_authenticated:
|
|
252
|
+
return
|
|
253
|
+
try:
|
|
254
|
+
count = self._c.messages.get_unread_count()
|
|
255
|
+
if self._last_message_count >= 0 and count > self._last_message_count:
|
|
256
|
+
self._fire("new_message", count)
|
|
257
|
+
self._last_message_count = count
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
roboat_utils.exceptions
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Exception hierarchy for roboat-utils.
|
|
5
|
+
|
|
6
|
+
Hierarchy
|
|
7
|
+
---------
|
|
8
|
+
RoboatAPIError
|
|
9
|
+
├── HTTPError
|
|
10
|
+
│ ├── RateLimitedError (429) — has .retry_after
|
|
11
|
+
│ ├── InvalidCookieError (401)
|
|
12
|
+
│ ├── ForbiddenError (403)
|
|
13
|
+
│ ├── NotFoundError (404)
|
|
14
|
+
│ │ ├── UserNotFoundError
|
|
15
|
+
│ │ ├── GameNotFoundError
|
|
16
|
+
│ │ ├── ItemNotFoundError
|
|
17
|
+
│ │ ├── GroupNotFoundError
|
|
18
|
+
│ │ └── BadgeNotFoundError
|
|
19
|
+
│ └── ServerError (5xx)
|
|
20
|
+
├── NotAuthenticatedError
|
|
21
|
+
├── InsufficientFundsError
|
|
22
|
+
└── DatabaseError
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RoboatAPIError(Exception):
|
|
30
|
+
"""Base class for all roboat-utils exceptions."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str = "", *, errors: list | None = None) -> None:
|
|
33
|
+
self.errors: list = errors or []
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
return f"{self.__class__.__name__}({self.args[0]!r})"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── HTTP layer ──────────────────────────────────────────────────────── #
|
|
41
|
+
|
|
42
|
+
class HTTPError(RoboatAPIError):
|
|
43
|
+
"""Raised for non-2xx HTTP responses from the Roblox API."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, status_code: int, message: str = "",
|
|
46
|
+
*, errors: list | None = None) -> None:
|
|
47
|
+
self.status_code = status_code
|
|
48
|
+
super().__init__(f"HTTP {status_code}: {message}", errors=errors)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_retryable(self) -> bool:
|
|
52
|
+
return self.status_code in (429, 500, 502, 503, 504)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RateLimitedError(HTTPError):
|
|
56
|
+
"""HTTP 429 — rate limit exceeded."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, retry_after: Optional[float] = None) -> None:
|
|
59
|
+
self.retry_after = retry_after
|
|
60
|
+
msg = "Rate limited by Roblox API."
|
|
61
|
+
if retry_after:
|
|
62
|
+
msg += f" Retry after {retry_after:.1f}s."
|
|
63
|
+
super().__init__(429, msg)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class InvalidCookieError(HTTPError):
|
|
67
|
+
"""HTTP 401 — the .ROBLOSECURITY cookie is invalid or expired."""
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
super().__init__(401, "Invalid or expired .ROBLOSECURITY cookie.")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ForbiddenError(HTTPError):
|
|
74
|
+
"""HTTP 403 — insufficient permissions."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, message: str = "Forbidden.") -> None:
|
|
77
|
+
super().__init__(403, message)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class NotFoundError(HTTPError):
|
|
81
|
+
"""HTTP 404 — resource not found."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, message: str = "Resource not found.") -> None:
|
|
84
|
+
super().__init__(404, message)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ServerError(HTTPError):
|
|
88
|
+
"""HTTP 5xx — Roblox-side server error."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, status_code: int, message: str = "") -> None:
|
|
91
|
+
super().__init__(status_code, message or f"Server error ({status_code}).")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── Not-found specialisations ────────────────────────────────────────── #
|
|
95
|
+
|
|
96
|
+
class UserNotFoundError(NotFoundError):
|
|
97
|
+
"""Raised when a user cannot be found by ID or username."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, identifier: int | str = "") -> None:
|
|
100
|
+
super().__init__(f"User not found: {identifier!r}")
|
|
101
|
+
self.identifier = identifier
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class GameNotFoundError(NotFoundError):
|
|
105
|
+
"""Raised when a universe/place cannot be found."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, universe_id: int | str = "") -> None:
|
|
108
|
+
super().__init__(f"Game not found: universe {universe_id!r}")
|
|
109
|
+
self.universe_id = universe_id
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ItemNotFoundError(NotFoundError):
|
|
113
|
+
"""Raised when a catalog item cannot be found."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, asset_id: int | str = "") -> None:
|
|
116
|
+
super().__init__(f"Catalog item not found: {asset_id!r}")
|
|
117
|
+
self.asset_id = asset_id
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class GroupNotFoundError(NotFoundError):
|
|
121
|
+
"""Raised when a group cannot be found."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, group_id: int | str = "") -> None:
|
|
124
|
+
super().__init__(f"Group not found: {group_id!r}")
|
|
125
|
+
self.group_id = group_id
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class BadgeNotFoundError(NotFoundError):
|
|
129
|
+
"""Raised when a badge cannot be found."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, badge_id: int | str = "") -> None:
|
|
132
|
+
super().__init__(f"Badge not found: {badge_id!r}")
|
|
133
|
+
self.badge_id = badge_id
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── Logic exceptions ─────────────────────────────────────────────────── #
|
|
137
|
+
|
|
138
|
+
class NotAuthenticatedError(RoboatAPIError):
|
|
139
|
+
"""Raised when a method requires authentication but none is set."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, method: str = "") -> None:
|
|
142
|
+
where = f" ({method})" if method else ""
|
|
143
|
+
super().__init__(
|
|
144
|
+
f"Authentication required{where}. "
|
|
145
|
+
"Pass a .ROBLOSECURITY cookie to RoboatClient."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class InsufficientFundsError(RoboatAPIError):
|
|
150
|
+
"""Raised when a purchase cannot be completed due to insufficient Robux."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, required: int = 0, available: int = 0) -> None:
|
|
153
|
+
msg = "Insufficient Robux."
|
|
154
|
+
if required and available:
|
|
155
|
+
msg = f"Need {required:,}R$ but only have {available:,}R$."
|
|
156
|
+
super().__init__(msg)
|
|
157
|
+
self.required = required
|
|
158
|
+
self.available = available
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class DatabaseError(RoboatAPIError):
|
|
162
|
+
"""Raised for errors relating to the local session database."""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── Helper ────────────────────────────────────────────────────────────── #
|
|
166
|
+
|
|
167
|
+
def raise_for_response(status_code: int, body: dict, url: str = "") -> None:
|
|
168
|
+
"""
|
|
169
|
+
Parse a Roblox API response and raise the appropriate exception.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
status_code: HTTP status code.
|
|
173
|
+
body: Parsed JSON response body.
|
|
174
|
+
url: Request URL (used to pick a more specific Not-Found subclass).
|
|
175
|
+
"""
|
|
176
|
+
if 200 <= status_code < 300:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
# Extract Roblox error message
|
|
180
|
+
errs = body.get("errors", [])
|
|
181
|
+
msg = "; ".join(e.get("message", "") for e in errs if e.get("message"))
|
|
182
|
+
if not msg:
|
|
183
|
+
msg = body.get("message", "")
|
|
184
|
+
|
|
185
|
+
if status_code == 401:
|
|
186
|
+
raise InvalidCookieError()
|
|
187
|
+
if status_code == 429:
|
|
188
|
+
raise RateLimitedError()
|
|
189
|
+
if status_code == 403:
|
|
190
|
+
raise ForbiddenError(msg or "Forbidden.")
|
|
191
|
+
if status_code == 404:
|
|
192
|
+
url_lower = url.lower()
|
|
193
|
+
if "users" in url_lower: raise UserNotFoundError(msg or url)
|
|
194
|
+
if "games" in url_lower: raise GameNotFoundError(msg or url)
|
|
195
|
+
if "catalog" in url_lower: raise ItemNotFoundError(msg or url)
|
|
196
|
+
if "groups" in url_lower: raise GroupNotFoundError(msg or url)
|
|
197
|
+
if "badges" in url_lower: raise BadgeNotFoundError(msg or url)
|
|
198
|
+
raise NotFoundError(msg or f"Not found: {url}")
|
|
199
|
+
if status_code >= 500:
|
|
200
|
+
raise ServerError(status_code, msg)
|
|
201
|
+
raise HTTPError(status_code, msg, errors=errs)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
__all__ = [
|
|
205
|
+
"RoboatAPIError",
|
|
206
|
+
"HTTPError",
|
|
207
|
+
"RateLimitedError",
|
|
208
|
+
"InvalidCookieError",
|
|
209
|
+
"ForbiddenError",
|
|
210
|
+
"NotFoundError",
|
|
211
|
+
"UserNotFoundError",
|
|
212
|
+
"GameNotFoundError",
|
|
213
|
+
"ItemNotFoundError",
|
|
214
|
+
"GroupNotFoundError",
|
|
215
|
+
"BadgeNotFoundError",
|
|
216
|
+
"ServerError",
|
|
217
|
+
"NotAuthenticatedError",
|
|
218
|
+
"InsufficientFundsError",
|
|
219
|
+
"DatabaseError",
|
|
220
|
+
"raise_for_response",
|
|
221
|
+
]
|
roboat_utils/friends.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
roboat.friends
|
|
3
|
+
~~~~~~~~~~~~~~~~~
|
|
4
|
+
Friends API — friends.roblox.com
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Optional, List
|
|
8
|
+
from roboat_utils.models import Friend, Page
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FriendsAPI:
|
|
12
|
+
BASE = "https://friends.roblox.com/v1"
|
|
13
|
+
|
|
14
|
+
def __init__(self, client):
|
|
15
|
+
self._c = client
|
|
16
|
+
|
|
17
|
+
def get_friends(self, user_id: int) -> List[Friend]:
|
|
18
|
+
"""Get the full friends list for a user."""
|
|
19
|
+
data = self._c._get(f"{self.BASE}/users/{user_id}/friends")
|
|
20
|
+
return [Friend.from_dict(f) for f in data.get("data", [])]
|
|
21
|
+
|
|
22
|
+
def get_friend_count(self, user_id: int) -> int:
|
|
23
|
+
"""Get the number of friends a user has."""
|
|
24
|
+
data = self._c._get(f"{self.BASE}/users/{user_id}/friends/count")
|
|
25
|
+
return data.get("count", 0)
|
|
26
|
+
|
|
27
|
+
def get_followers(self, user_id: int, limit: int = 100,
|
|
28
|
+
cursor: Optional[str] = None) -> Page:
|
|
29
|
+
"""Get users who follow a given user."""
|
|
30
|
+
params = {"limit": limit, "sortOrder": "Asc"}
|
|
31
|
+
if cursor: params["cursor"] = cursor
|
|
32
|
+
data = self._c._get(f"{self.BASE}/users/{user_id}/followers", params=params)
|
|
33
|
+
return Page.from_dict(data, Friend)
|
|
34
|
+
|
|
35
|
+
def get_follower_count(self, user_id: int) -> int:
|
|
36
|
+
"""Get the follower count for a user."""
|
|
37
|
+
data = self._c._get(f"{self.BASE}/users/{user_id}/followers/count")
|
|
38
|
+
return data.get("count", 0)
|
|
39
|
+
|
|
40
|
+
def get_followings(self, user_id: int, limit: int = 100,
|
|
41
|
+
cursor: Optional[str] = None) -> Page:
|
|
42
|
+
"""Get users that a user is following."""
|
|
43
|
+
params = {"limit": limit, "sortOrder": "Asc"}
|
|
44
|
+
if cursor: params["cursor"] = cursor
|
|
45
|
+
data = self._c._get(f"{self.BASE}/users/{user_id}/followings", params=params)
|
|
46
|
+
return Page.from_dict(data, Friend)
|
|
47
|
+
|
|
48
|
+
def get_following_count(self, user_id: int) -> int:
|
|
49
|
+
"""Get how many users a user is following."""
|
|
50
|
+
data = self._c._get(f"{self.BASE}/users/{user_id}/followings/count")
|
|
51
|
+
return data.get("count", 0)
|
|
52
|
+
|
|
53
|
+
def get_friend_requests(self, limit: int = 20,
|
|
54
|
+
cursor: Optional[str] = None) -> Page:
|
|
55
|
+
"""Get pending friend requests. Requires auth."""
|
|
56
|
+
self._c.require_auth("get_friend_requests")
|
|
57
|
+
params = {"limit": limit}
|
|
58
|
+
if cursor: params["cursor"] = cursor
|
|
59
|
+
data = self._c._get(f"{self.BASE}/my/friends/requests", params=params)
|
|
60
|
+
return Page.from_dict(data, Friend)
|
|
61
|
+
|
|
62
|
+
def send_friend_request(self, user_id: int) -> None:
|
|
63
|
+
"""Send a friend request. Requires auth."""
|
|
64
|
+
self._c.require_auth("send_friend_request")
|
|
65
|
+
self._c._post(f"{self.BASE}/users/{user_id}/request-friendship")
|
|
66
|
+
|
|
67
|
+
def unfriend(self, user_id: int) -> None:
|
|
68
|
+
"""Unfriend a user. Requires auth."""
|
|
69
|
+
self._c.require_auth("unfriend")
|
|
70
|
+
self._c._post(f"{self.BASE}/users/{user_id}/unfriend")
|
|
71
|
+
|
|
72
|
+
def accept_friend_request(self, user_id: int) -> None:
|
|
73
|
+
"""Accept a friend request. Requires auth."""
|
|
74
|
+
self._c.require_auth("accept_friend_request")
|
|
75
|
+
self._c._post(f"{self.BASE}/users/{user_id}/accept-friend-request")
|
|
76
|
+
|
|
77
|
+
def decline_friend_request(self, user_id: int) -> None:
|
|
78
|
+
"""Decline a friend request. Requires auth."""
|
|
79
|
+
self._c.require_auth("decline_friend_request")
|
|
80
|
+
self._c._post(f"{self.BASE}/users/{user_id}/decline-friend-request")
|