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.
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
+ ]
@@ -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")