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/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']}>"
|
roboat_utils/database.py
ADDED
|
@@ -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}>"
|