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/client.py ADDED
@@ -0,0 +1,332 @@
1
+ """
2
+ roboat_utils.client
3
+ ~~~~~~~~~~~~~~~~~~~~
4
+ RoboatClient and ClientBuilder — main entry points.
5
+
6
+ Bug fixes vs original:
7
+ - ClientBuilder had wrong attribute names (set_cookie stored to self._cookie
8
+ but method was named set_oauth_token)
9
+ - RoboatClient.__init__ referenced undefined `cookie` variable
10
+ - set_oauth_token set self._oauth_token = oauth_token BEFORE assigning param
11
+ - robux() method had dead code after raise NotImplementedError
12
+ - _handle_response 403 branch didn't return; fell through to raise
13
+ - _get/_post/_patch/_delete didn't pass url to raise_for_response for
14
+ accurate not-found subclass selection
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import requests
20
+ from typing import Optional
21
+
22
+ from .exceptions import (
23
+ RoboatAPIError, RateLimitedError, NotAuthenticatedError, raise_for_response,
24
+ )
25
+ from .utils.cache import TTLCache
26
+ from .utils.ratelimit import TokenBucket
27
+
28
+
29
+ class ClientBuilder:
30
+ """
31
+ Fluent builder for RoboatClient.
32
+
33
+ Example::
34
+
35
+ client = (
36
+ ClientBuilder()
37
+ .set_cookie("_|WARNING:...")
38
+ .set_timeout(15)
39
+ .set_cache_ttl(60)
40
+ .set_rate_limit(10)
41
+ .build()
42
+ )
43
+ """
44
+
45
+ def __init__(self) -> None:
46
+ self._cookie: Optional[str] = None
47
+ self._timeout: int = 10
48
+ self._proxies: Optional[dict] = None
49
+ self._cache_ttl: float = 30.0
50
+ self._cache_size: int = 512
51
+ self._rate_limit: float = 10.0
52
+
53
+ def set_cookie(self, cookie: str) -> "ClientBuilder":
54
+ """Set the .ROBLOSECURITY cookie for authenticated requests."""
55
+ self._cookie = cookie
56
+ return self
57
+
58
+ # Keep old name for backwards compatibility
59
+ def set_oauth_token(self, token: str) -> "ClientBuilder":
60
+ """Alias for set_cookie (the original used this name incorrectly)."""
61
+ self._cookie = token
62
+ return self
63
+
64
+ def set_timeout(self, seconds: int) -> "ClientBuilder":
65
+ self._timeout = seconds
66
+ return self
67
+
68
+ def set_proxy(self, http: str, https: Optional[str] = None) -> "ClientBuilder":
69
+ self._proxies = {"http": http, "https": https or http}
70
+ return self
71
+
72
+ def set_cache_ttl(self, seconds: float) -> "ClientBuilder":
73
+ """Set how long API responses are cached. Pass 0 to disable."""
74
+ self._cache_ttl = max(0.0, seconds)
75
+ return self
76
+
77
+ def set_cache_size(self, max_entries: int) -> "ClientBuilder":
78
+ self._cache_size = max_entries
79
+ return self
80
+
81
+ def set_rate_limit(self, requests_per_second: float) -> "ClientBuilder":
82
+ self._rate_limit = requests_per_second
83
+ return self
84
+
85
+ def build(self) -> "RoboatClient":
86
+ return RoboatClient(
87
+ cookie=self._cookie,
88
+ timeout=self._timeout,
89
+ proxies=self._proxies,
90
+ cache_ttl=self._cache_ttl,
91
+ cache_size=self._cache_size,
92
+ rate_limit=self._rate_limit,
93
+ )
94
+
95
+
96
+ class RoboatClient:
97
+ """
98
+ Full-featured Roblox API client.
99
+
100
+ Sub-APIs
101
+ --------
102
+ client.users — User lookup, search, username history
103
+ client.games — Games, visits, servers, votes, passes
104
+ client.catalog — Avatar shop, items, resale, bundles
105
+ client.groups — Groups, roles, members, wall, audit log
106
+ client.friends — Friends, followers, followings, requests
107
+ client.thumbnails — Thumbnail URLs for any resource type
108
+ client.badges — Badges and award dates
109
+ client.economy — Robux, transactions, resellers
110
+ client.presence — Online / in-game status
111
+ client.avatar — Avatar assets, colors, scales, outfits
112
+ client.trades — Trade list, details, send, accept, decline
113
+ client.messages — Private messages and chat
114
+ client.chat — Chat conversations
115
+ client.inventory — Inventory, collectibles, ownership checks
116
+ client.develop — Universe/place management, datastores, stats
117
+ client.events — Polling-based event system
118
+
119
+ Example::
120
+
121
+ client = RoboatClient(cookie="_|WARNING:...")
122
+ user = client.users.get_user(156)
123
+ print(user)
124
+ """
125
+
126
+ _BASE_HEADERS = {
127
+ "Accept": "application/json",
128
+ "Content-Type": "application/json",
129
+ "User-Agent": "roboat-utils/3.0 (https://github.com/Addi9000/roboat)",
130
+ }
131
+
132
+ def __init__(
133
+ self,
134
+ cookie: Optional[str] = None,
135
+ timeout: int = 10,
136
+ proxies: Optional[dict] = None,
137
+ cache_ttl: float = 30.0,
138
+ cache_size: int = 512,
139
+ rate_limit: float = 10.0,
140
+ ) -> None:
141
+ self._cookie: Optional[str] = None
142
+ self._csrf_token: Optional[str] = None
143
+ self._timeout = timeout
144
+ self._cache = TTLCache(default_ttl=cache_ttl, max_size=cache_size)
145
+ self._bucket = TokenBucket(rate=rate_limit, capacity=rate_limit * 2)
146
+
147
+ self._session = requests.Session()
148
+ self._session.headers.update(self._BASE_HEADERS)
149
+ if proxies:
150
+ self._session.proxies.update(proxies)
151
+
152
+ # Attach sub-APIs (lazy imports avoid circular deps)
153
+ from .users import UsersAPI
154
+ from .games import GamesAPI
155
+ from .catalog import CatalogAPI
156
+ from .groups import GroupsAPI
157
+ from .friends import FriendsAPI
158
+ from .thumbnails import ThumbnailsAPI
159
+ from .badges import BadgesAPI
160
+ from .economy import EconomyAPI
161
+ from .presence import PresenceAPI
162
+ from .avatar import AvatarAPI
163
+ from .trades import TradesAPI
164
+ from .messages import MessagesAPI, ChatAPI
165
+ from .inventory import InventoryAPI
166
+ from .develop import DevelopAPI
167
+ from .events import EventPoller
168
+
169
+ self.users = UsersAPI(self)
170
+ self.games = GamesAPI(self)
171
+ self.catalog = CatalogAPI(self)
172
+ self.groups = GroupsAPI(self)
173
+ self.friends = FriendsAPI(self)
174
+ self.thumbnails = ThumbnailsAPI(self)
175
+ self.badges = BadgesAPI(self)
176
+ self.economy = EconomyAPI(self)
177
+ self.presence = PresenceAPI(self)
178
+ self.avatar = AvatarAPI(self)
179
+ self.trades = TradesAPI(self)
180
+ self.messages = MessagesAPI(self)
181
+ self.chat = ChatAPI(self)
182
+ self.inventory = InventoryAPI(self)
183
+ self.develop = DevelopAPI(self)
184
+ self.events = EventPoller(self)
185
+
186
+ if cookie:
187
+ self.set_cookie(cookie)
188
+
189
+ # ── Auth ──────────────────────────────────────────────────────── #
190
+
191
+ def set_cookie(self, cookie: str) -> None:
192
+ """Set (or replace) the .ROBLOSECURITY cookie and refresh CSRF."""
193
+ self._cookie = cookie
194
+ self._session.cookies.set(".ROBLOSECURITY", cookie, domain=".roblox.com")
195
+ self._refresh_csrf()
196
+
197
+ # Backwards-compat alias used by original codebase
198
+ def set_oauth_token(self, token: str) -> None:
199
+ """Alias for set_cookie."""
200
+ self.set_cookie(token)
201
+
202
+ def _refresh_csrf(self) -> None:
203
+ """Silently refresh the X-CSRF-TOKEN from the logout endpoint."""
204
+ try:
205
+ r = self._session.post(
206
+ "https://auth.roblox.com/v2/logout",
207
+ timeout=self._timeout,
208
+ )
209
+ token = r.headers.get("x-csrf-token")
210
+ if token:
211
+ self._csrf_token = token
212
+ self._session.headers["x-csrf-token"] = token
213
+ except Exception:
214
+ pass
215
+
216
+ @property
217
+ def is_authenticated(self) -> bool:
218
+ return self._cookie is not None
219
+
220
+ def require_auth(self, method: str = "") -> None:
221
+ """Raise NotAuthenticatedError if no cookie is set."""
222
+ if not self.is_authenticated:
223
+ raise NotAuthenticatedError(method)
224
+
225
+ # ── HTTP helpers ─────────────────────────────────────────────── #
226
+
227
+ def _handle_response(self, resp: requests.Response) -> dict:
228
+ """
229
+ Parse a response, refreshing CSRF on 403 (Roblox-specific pattern)
230
+ and raising an appropriate exception on any other error.
231
+ """
232
+ # Roblox returns 403 with a new CSRF token when ours is stale.
233
+ # We DON'T want to raise here — the caller re-tries with the new token.
234
+ if resp.status_code == 403:
235
+ token = resp.headers.get("x-csrf-token")
236
+ if token:
237
+ self._csrf_token = token
238
+ self._session.headers["x-csrf-token"] = token
239
+ # Only re-raise if there's no token (genuine forbidden)
240
+ if not token:
241
+ raise_for_response(403, {}, url=resp.url)
242
+ return {} # signal caller to retry
243
+
244
+ if not resp.ok:
245
+ try:
246
+ body = resp.json()
247
+ except Exception:
248
+ body = {}
249
+ raise_for_response(resp.status_code, body, url=str(resp.url))
250
+
251
+ try:
252
+ return resp.json()
253
+ except Exception:
254
+ return {}
255
+
256
+ def _get(self, url: str, **kwargs) -> dict:
257
+ self._bucket.consume()
258
+ kwargs.setdefault("timeout", self._timeout)
259
+ resp = self._session.get(url, **kwargs)
260
+ return self._handle_response(resp)
261
+
262
+ def _post(self, url: str, **kwargs) -> dict:
263
+ self._bucket.consume()
264
+ kwargs.setdefault("timeout", self._timeout)
265
+ resp = self._session.post(url, **kwargs)
266
+ result = self._handle_response(resp)
267
+ # Empty dict signals a stale CSRF — retry once
268
+ if result == {} and resp.status_code == 403:
269
+ resp = self._session.post(url, **kwargs)
270
+ return self._handle_response(resp)
271
+ return result
272
+
273
+ def _patch(self, url: str, **kwargs) -> dict:
274
+ self._bucket.consume()
275
+ kwargs.setdefault("timeout", self._timeout)
276
+ resp = self._session.patch(url, **kwargs)
277
+ result = self._handle_response(resp)
278
+ if result == {} and resp.status_code == 403:
279
+ resp = self._session.patch(url, **kwargs)
280
+ return self._handle_response(resp)
281
+ return result
282
+
283
+ def _delete(self, url: str, **kwargs) -> dict:
284
+ self._bucket.consume()
285
+ kwargs.setdefault("timeout", self._timeout)
286
+ resp = self._session.delete(url, **kwargs)
287
+ result = self._handle_response(resp)
288
+ if result == {} and resp.status_code == 403:
289
+ resp = self._session.delete(url, **kwargs)
290
+ return self._handle_response(resp)
291
+ return result
292
+
293
+ # ── Convenience shortcuts ─────────────────────────────────────── #
294
+
295
+ def user_id(self) -> int:
296
+ """Get the authenticated user's ID."""
297
+ self.require_auth("user_id")
298
+ return self.users.get_authenticated_user()["id"]
299
+
300
+ def username(self) -> str:
301
+ """Get the authenticated user's username."""
302
+ self.require_auth("username")
303
+ return self.users.get_authenticated_user()["name"]
304
+
305
+ def display_name(self) -> str:
306
+ """Get the authenticated user's display name."""
307
+ self.require_auth("display_name")
308
+ return self.users.get_authenticated_user()["displayName"]
309
+
310
+ def robux(self) -> int:
311
+ """Get the authenticated user's Robux balance."""
312
+ self.require_auth("robux")
313
+ uid = self.user_id()
314
+ return self.economy.get_robux_balance(uid).robux
315
+
316
+ def total_rap(self) -> int:
317
+ """Get total RAP of the authenticated user's limiteds."""
318
+ self.require_auth("total_rap")
319
+ return self.inventory.get_total_rap(self.user_id())
320
+
321
+ def invalidate_cache(self) -> None:
322
+ """Clear the entire response cache."""
323
+ self._cache.clear()
324
+
325
+ def cache_stats(self) -> dict:
326
+ """Return cache utilisation stats."""
327
+ return self._cache.stats()
328
+
329
+ def __repr__(self) -> str:
330
+ auth = "authenticated" if self.is_authenticated else "unauthenticated"
331
+ cache = self._cache.stats()
332
+ return f"<RoboatClient [{auth}] cache={cache['alive']}/{cache['max_size']}>"
@@ -0,0 +1,258 @@
1
+ """
2
+ roboat.database
3
+ ~~~~~~~~~~~~~~~~~~
4
+ Local SQLite-backed session database.
5
+ Stores user data, game data, and session history locally.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import sqlite3
10
+ import json
11
+ import os
12
+ from datetime import datetime
13
+ from typing import Optional, Any
14
+ from roboat_utils.exceptions import DatabaseError
15
+
16
+
17
+ class SessionDatabase:
18
+ """
19
+ Lightweight local SQLite database for persisting roboat session data.
20
+
21
+ Usage::
22
+
23
+ db = SessionDatabase.create("mysession") # new DB
24
+ db = SessionDatabase.load("mysession") # existing DB
25
+
26
+ Tables:
27
+ users — cached user records
28
+ games — cached game records
29
+ sessions — session metadata / notes
30
+ log — command history
31
+ """
32
+
33
+ _EXTENSION = ".robloxdb"
34
+
35
+ def __init__(self, path: str):
36
+ self.path = path
37
+ self.name = os.path.splitext(os.path.basename(path))[0]
38
+ self._conn: Optional[sqlite3.Connection] = None
39
+ self._connect()
40
+ self._init_schema()
41
+
42
+ # ------------------------------------------------------------------ #
43
+ # Factory methods #
44
+ # ------------------------------------------------------------------ #
45
+
46
+ @classmethod
47
+ def create(cls, name: str, directory: str = ".") -> "SessionDatabase":
48
+ """Create a new database. Raises if it already exists."""
49
+ path = cls._resolve_path(name, directory)
50
+ if os.path.exists(path):
51
+ raise DatabaseError(f"Database '{name}' already exists at {path}. Use load() instead.")
52
+ return cls(path)
53
+
54
+ @classmethod
55
+ def load(cls, name: str, directory: str = ".") -> "SessionDatabase":
56
+ """Load an existing database. Raises if not found."""
57
+ path = cls._resolve_path(name, directory)
58
+ if not os.path.exists(path):
59
+ raise DatabaseError(f"Database '{name}' not found at {path}. Use create() instead.")
60
+ return cls(path)
61
+
62
+ @classmethod
63
+ def load_or_create(cls, name: str, directory: str = ".") -> "SessionDatabase":
64
+ """Load if exists, otherwise create."""
65
+ path = cls._resolve_path(name, directory)
66
+ return cls(path)
67
+
68
+ @classmethod
69
+ def _resolve_path(cls, name: str, directory: str) -> str:
70
+ if not name.endswith(cls._EXTENSION):
71
+ name = name + cls._EXTENSION
72
+ return os.path.join(directory, name)
73
+
74
+ @classmethod
75
+ def list_databases(cls, directory: str = ".") -> list:
76
+ """List all .robloxdb files in a directory."""
77
+ try:
78
+ return [
79
+ f[:-len(cls._EXTENSION)]
80
+ for f in os.listdir(directory)
81
+ if f.endswith(cls._EXTENSION)
82
+ ]
83
+ except FileNotFoundError:
84
+ return []
85
+
86
+ # ------------------------------------------------------------------ #
87
+ # Connection #
88
+ # ------------------------------------------------------------------ #
89
+
90
+ def _connect(self):
91
+ self._conn = sqlite3.connect(self.path)
92
+ self._conn.row_factory = sqlite3.Row
93
+
94
+ def close(self):
95
+ if self._conn:
96
+ self._conn.close()
97
+ self._conn = None
98
+
99
+ def _init_schema(self):
100
+ self._conn.executescript("""
101
+ CREATE TABLE IF NOT EXISTS users (
102
+ user_id INTEGER PRIMARY KEY,
103
+ username TEXT,
104
+ display_name TEXT,
105
+ description TEXT,
106
+ is_banned INTEGER DEFAULT 0,
107
+ verified INTEGER DEFAULT 0,
108
+ cached_at TEXT
109
+ );
110
+
111
+ CREATE TABLE IF NOT EXISTS games (
112
+ universe_id INTEGER PRIMARY KEY,
113
+ place_id INTEGER,
114
+ name TEXT,
115
+ description TEXT,
116
+ creator_name TEXT,
117
+ visits INTEGER DEFAULT 0,
118
+ playing INTEGER DEFAULT 0,
119
+ max_players INTEGER DEFAULT 0,
120
+ genre TEXT,
121
+ cached_at TEXT
122
+ );
123
+
124
+ CREATE TABLE IF NOT EXISTS sessions (
125
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
126
+ key TEXT UNIQUE,
127
+ value TEXT,
128
+ updated_at TEXT
129
+ );
130
+
131
+ CREATE TABLE IF NOT EXISTS log (
132
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
133
+ command TEXT,
134
+ result TEXT,
135
+ ran_at TEXT
136
+ );
137
+ """)
138
+ self._conn.commit()
139
+
140
+ # ------------------------------------------------------------------ #
141
+ # Users #
142
+ # ------------------------------------------------------------------ #
143
+
144
+ def save_user(self, user) -> None:
145
+ """Cache a User model to the database."""
146
+ self._conn.execute("""
147
+ INSERT OR REPLACE INTO users
148
+ (user_id, username, display_name, description, is_banned, verified, cached_at)
149
+ VALUES (?, ?, ?, ?, ?, ?, ?)
150
+ """, (
151
+ user.id, user.name, user.display_name, user.description,
152
+ int(user.is_banned), int(user.has_verified_badge),
153
+ datetime.utcnow().isoformat(),
154
+ ))
155
+ self._conn.commit()
156
+
157
+ def get_user(self, user_id: int) -> Optional[dict]:
158
+ """Retrieve a cached user by ID."""
159
+ row = self._conn.execute(
160
+ "SELECT * FROM users WHERE user_id = ?", (user_id,)
161
+ ).fetchone()
162
+ return dict(row) if row else None
163
+
164
+ def get_all_users(self) -> list:
165
+ rows = self._conn.execute("SELECT * FROM users ORDER BY cached_at DESC").fetchall()
166
+ return [dict(r) for r in rows]
167
+
168
+ # ------------------------------------------------------------------ #
169
+ # Games #
170
+ # ------------------------------------------------------------------ #
171
+
172
+ def save_game(self, game) -> None:
173
+ """Cache a Game model to the database."""
174
+ self._conn.execute("""
175
+ INSERT OR REPLACE INTO games
176
+ (universe_id, place_id, name, description, creator_name,
177
+ visits, playing, max_players, genre, cached_at)
178
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
179
+ """, (
180
+ game.id, game.root_place_id, game.name, game.description,
181
+ game.creator_name, game.visits, game.playing, game.max_players,
182
+ game.genre, datetime.utcnow().isoformat(),
183
+ ))
184
+ self._conn.commit()
185
+
186
+ def get_game(self, universe_id: int) -> Optional[dict]:
187
+ row = self._conn.execute(
188
+ "SELECT * FROM games WHERE universe_id = ?", (universe_id,)
189
+ ).fetchone()
190
+ return dict(row) if row else None
191
+
192
+ def get_all_games(self) -> list:
193
+ rows = self._conn.execute("SELECT * FROM games ORDER BY visits DESC").fetchall()
194
+ return [dict(r) for r in rows]
195
+
196
+ # ------------------------------------------------------------------ #
197
+ # Session key-value store #
198
+ # ------------------------------------------------------------------ #
199
+
200
+ def set(self, key: str, value: Any) -> None:
201
+ """Store any JSON-serialisable value under a key."""
202
+ self._conn.execute("""
203
+ INSERT OR REPLACE INTO sessions (key, value, updated_at)
204
+ VALUES (?, ?, ?)
205
+ """, (key, json.dumps(value), datetime.utcnow().isoformat()))
206
+ self._conn.commit()
207
+
208
+ def get(self, key: str, default: Any = None) -> Any:
209
+ """Retrieve a stored value by key."""
210
+ row = self._conn.execute(
211
+ "SELECT value FROM sessions WHERE key = ?", (key,)
212
+ ).fetchone()
213
+ if row:
214
+ try:
215
+ return json.loads(row["value"])
216
+ except Exception:
217
+ return row["value"]
218
+ return default
219
+
220
+ def delete(self, key: str) -> None:
221
+ self._conn.execute("DELETE FROM sessions WHERE key = ?", (key,))
222
+ self._conn.commit()
223
+
224
+ def keys(self) -> list:
225
+ rows = self._conn.execute("SELECT key FROM sessions").fetchall()
226
+ return [r["key"] for r in rows]
227
+
228
+ # ------------------------------------------------------------------ #
229
+ # Log #
230
+ # ------------------------------------------------------------------ #
231
+
232
+ def log_command(self, command: str, result: str = "") -> None:
233
+ """Log a terminal command and its result."""
234
+ self._conn.execute(
235
+ "INSERT INTO log (command, result, ran_at) VALUES (?, ?, ?)",
236
+ (command, result[:500], datetime.utcnow().isoformat()),
237
+ )
238
+ self._conn.commit()
239
+
240
+ def get_log(self, limit: int = 50) -> list:
241
+ rows = self._conn.execute(
242
+ "SELECT * FROM log ORDER BY ran_at DESC LIMIT ?", (limit,)
243
+ ).fetchall()
244
+ return [dict(r) for r in rows]
245
+
246
+ # ------------------------------------------------------------------ #
247
+ # Stats #
248
+ # ------------------------------------------------------------------ #
249
+
250
+ def stats(self) -> dict:
251
+ users = self._conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
252
+ games = self._conn.execute("SELECT COUNT(*) FROM games").fetchone()[0]
253
+ logs = self._conn.execute("SELECT COUNT(*) FROM log").fetchone()[0]
254
+ keys = self._conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
255
+ return {"users": users, "games": games, "session_keys": keys, "log_entries": logs}
256
+
257
+ def __repr__(self) -> str:
258
+ return f"<SessionDatabase '{self.name}' at {self.path}>"