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,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
roboat.inventory
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Inventory API — inventory.roblox.com + economy.roblox.com
|
|
5
|
+
Check asset ownership, list user inventory, asset details.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
from roboat_utils.models import Page
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class InventoryAsset:
|
|
16
|
+
user_asset_id: int
|
|
17
|
+
asset_id: int
|
|
18
|
+
name: str
|
|
19
|
+
asset_type_id: int
|
|
20
|
+
created: str
|
|
21
|
+
updated: str
|
|
22
|
+
serial_number: Optional[int] = None
|
|
23
|
+
is_tradable: bool = False
|
|
24
|
+
is_recent_average_price_valid: bool = False
|
|
25
|
+
recent_average_price: int = 0
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_dict(cls, d: dict) -> "InventoryAsset":
|
|
29
|
+
return cls(
|
|
30
|
+
user_asset_id=d.get("userAssetId", 0),
|
|
31
|
+
asset_id=d.get("assetId", 0),
|
|
32
|
+
name=d.get("name", ""),
|
|
33
|
+
asset_type_id=d.get("assetTypeId", 0),
|
|
34
|
+
created=d.get("created", ""),
|
|
35
|
+
updated=d.get("updated", ""),
|
|
36
|
+
serial_number=d.get("serialNumber"),
|
|
37
|
+
is_tradable=d.get("isTradable", False),
|
|
38
|
+
is_recent_average_price_valid=d.get("isRecentAveragePriceValid", False),
|
|
39
|
+
recent_average_price=d.get("recentAveragePrice", 0),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def __str__(self) -> str:
|
|
43
|
+
serial = f" #{self.serial_number}" if self.serial_number else ""
|
|
44
|
+
tradable = " [Tradable]" if self.is_tradable else ""
|
|
45
|
+
rap = f" RAP: {self.recent_average_price:,}R$" if self.recent_average_price else ""
|
|
46
|
+
return f"{self.name}{serial}{tradable}{rap}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InventoryAPI:
|
|
50
|
+
BASE = "https://inventory.roblox.com/v1"
|
|
51
|
+
BASE2 = "https://inventory.roblox.com/v2"
|
|
52
|
+
ECON = "https://economy.roblox.com/v1"
|
|
53
|
+
|
|
54
|
+
def __init__(self, client):
|
|
55
|
+
self._c = client
|
|
56
|
+
|
|
57
|
+
def get_user_inventory(self, user_id: int, asset_type_ids: List[int],
|
|
58
|
+
limit: int = 100,
|
|
59
|
+
cursor: Optional[str] = None) -> Page:
|
|
60
|
+
"""
|
|
61
|
+
Get a user's inventory filtered by asset type.
|
|
62
|
+
|
|
63
|
+
Common asset_type_ids:
|
|
64
|
+
2 = T-Shirt 11 = Shirt 12 = Pants
|
|
65
|
+
8 = Hat 17 = Head 18 = Face
|
|
66
|
+
19 = Gear 25 = Body Parts 27 = Torso
|
|
67
|
+
28 = Right Arm 29 = Left Arm 30 = Right Leg
|
|
68
|
+
31 = Left Leg 41 = Hair 42 = Emote
|
|
69
|
+
43 = Shoulder 44 = Front 45 = Back
|
|
70
|
+
46 = Waist 47 = Climb 48 = Fall
|
|
71
|
+
49 = Jump 50 = Run 51 = Swim
|
|
72
|
+
52 = Walk 53 = Idle 54 = Animation
|
|
73
|
+
61 = Bundle 62 = AnimationBundle
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Page of InventoryAsset objects.
|
|
77
|
+
"""
|
|
78
|
+
params = {
|
|
79
|
+
"assetTypeIds": ",".join(str(i) for i in asset_type_ids),
|
|
80
|
+
"limit": limit,
|
|
81
|
+
"sortOrder": "Asc",
|
|
82
|
+
}
|
|
83
|
+
if cursor:
|
|
84
|
+
params["cursor"] = cursor
|
|
85
|
+
data = self._c._get(
|
|
86
|
+
f"{self.BASE}/users/{user_id}/assets/collectibles",
|
|
87
|
+
params=params,
|
|
88
|
+
)
|
|
89
|
+
assets = [InventoryAsset.from_dict(a) for a in data.get("data", [])]
|
|
90
|
+
return Page(
|
|
91
|
+
data=assets,
|
|
92
|
+
next_cursor=data.get("nextPageCursor"),
|
|
93
|
+
previous_cursor=data.get("previousPageCursor"),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def get_collectibles(self, user_id: int, limit: int = 100,
|
|
97
|
+
cursor: Optional[str] = None) -> Page:
|
|
98
|
+
"""
|
|
99
|
+
Get all tradable/collectible (limited) items owned by a user.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Page of InventoryAsset objects with RAP data.
|
|
103
|
+
"""
|
|
104
|
+
params = {"limit": limit}
|
|
105
|
+
if cursor:
|
|
106
|
+
params["cursor"] = cursor
|
|
107
|
+
data = self._c._get(
|
|
108
|
+
f"{self.BASE}/users/{user_id}/assets/collectibles",
|
|
109
|
+
params=params,
|
|
110
|
+
)
|
|
111
|
+
assets = [InventoryAsset.from_dict(a) for a in data.get("data", [])]
|
|
112
|
+
return Page(
|
|
113
|
+
data=assets,
|
|
114
|
+
next_cursor=data.get("nextPageCursor"),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def owns_asset(self, user_id: int, asset_id: int) -> bool:
|
|
118
|
+
"""Check if a user owns a specific asset. Returns True/False."""
|
|
119
|
+
try:
|
|
120
|
+
data = self._c._get(
|
|
121
|
+
f"{self.BASE}/users/{user_id}/items/1/{asset_id}/is-owned"
|
|
122
|
+
)
|
|
123
|
+
return bool(data)
|
|
124
|
+
except Exception:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
def owns_badge(self, user_id: int, badge_id: int) -> bool:
|
|
128
|
+
"""Check if a user has earned a specific badge."""
|
|
129
|
+
try:
|
|
130
|
+
data = self._c._get(
|
|
131
|
+
f"https://badges.roblox.com/v1/users/{user_id}/badges/awarded-dates",
|
|
132
|
+
params={"badgeIds": badge_id},
|
|
133
|
+
)
|
|
134
|
+
return len(data.get("data", [])) > 0
|
|
135
|
+
except Exception:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
def owns_game_pass(self, user_id: int, game_pass_id: int) -> bool:
|
|
139
|
+
"""Check if a user owns a game pass."""
|
|
140
|
+
try:
|
|
141
|
+
data = self._c._get(
|
|
142
|
+
f"https://inventory.roblox.com/v1/users/{user_id}/items/GamePass/{game_pass_id}/is-owned"
|
|
143
|
+
)
|
|
144
|
+
return bool(data)
|
|
145
|
+
except Exception:
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
def get_user_item_types(self, user_id: int) -> List[dict]:
|
|
149
|
+
"""Get all item type categories available in a user's inventory."""
|
|
150
|
+
data = self._c._get(
|
|
151
|
+
f"{self.BASE2}/users/{user_id}/inventory",
|
|
152
|
+
params={"limit": 100},
|
|
153
|
+
)
|
|
154
|
+
return data.get("data", [])
|
|
155
|
+
|
|
156
|
+
def get_user_hats(self, user_id: int, limit: int = 100) -> Page:
|
|
157
|
+
"""Shortcut: Get all hats owned by a user."""
|
|
158
|
+
return self.get_user_inventory(user_id, [8], limit=limit)
|
|
159
|
+
|
|
160
|
+
def get_user_faces(self, user_id: int, limit: int = 100) -> Page:
|
|
161
|
+
"""Shortcut: Get all faces owned by a user."""
|
|
162
|
+
return self.get_user_inventory(user_id, [18], limit=limit)
|
|
163
|
+
|
|
164
|
+
def get_user_gear(self, user_id: int, limit: int = 100) -> Page:
|
|
165
|
+
"""Shortcut: Get all gear owned by a user."""
|
|
166
|
+
return self.get_user_inventory(user_id, [19], limit=limit)
|
|
167
|
+
|
|
168
|
+
def get_user_clothes(self, user_id: int, limit: int = 100) -> Page:
|
|
169
|
+
"""Shortcut: Get all clothing (shirt, pants, t-shirt) owned by a user."""
|
|
170
|
+
return self.get_user_inventory(user_id, [2, 11, 12], limit=limit)
|
|
171
|
+
|
|
172
|
+
def get_total_rap(self, user_id: int) -> int:
|
|
173
|
+
"""
|
|
174
|
+
Calculate the total Recent Average Price (RAP) of a user's limiteds.
|
|
175
|
+
Iterates through all pages of collectibles.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
int: Total RAP in Robux.
|
|
179
|
+
"""
|
|
180
|
+
total = 0
|
|
181
|
+
cursor = None
|
|
182
|
+
while True:
|
|
183
|
+
page = self.get_collectibles(user_id, limit=100, cursor=cursor)
|
|
184
|
+
for asset in page.data:
|
|
185
|
+
total += asset.recent_average_price
|
|
186
|
+
cursor = page.next_cursor
|
|
187
|
+
if not cursor:
|
|
188
|
+
break
|
|
189
|
+
return total
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""
|
|
2
|
+
roboat_utils.marketplace
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Marketplace tools — limited items, collectibles, UGC,
|
|
5
|
+
purchase history, and economy monitoring.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
from roboat_utils.marketplace import MarketplaceAPI
|
|
10
|
+
|
|
11
|
+
market = MarketplaceAPI(client)
|
|
12
|
+
data = market.get_limited_data(1365767)
|
|
13
|
+
print(data)
|
|
14
|
+
|
|
15
|
+
profit = market.estimate_resale_profit(1365767, purchase_price=5000)
|
|
16
|
+
print(profit)
|
|
17
|
+
|
|
18
|
+
tracker = market.create_rap_tracker([1365767, 1028606])
|
|
19
|
+
tracker.snapshot()
|
|
20
|
+
changes = tracker.diff()
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Dict, List, Optional
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class LimitedData:
|
|
31
|
+
asset_id: int
|
|
32
|
+
name: str
|
|
33
|
+
asset_stock: Optional[int]
|
|
34
|
+
sales: int
|
|
35
|
+
number_remaining: Optional[int]
|
|
36
|
+
recent_average_price: int
|
|
37
|
+
original_price: Optional[int]
|
|
38
|
+
lowest_resale_price: Optional[int]
|
|
39
|
+
price_data_points: List[dict] = field(default_factory=list)
|
|
40
|
+
volume_data_points: List[dict] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_resale_dict(
|
|
44
|
+
cls, asset_id: int, resale: dict, details: Optional[dict] = None
|
|
45
|
+
) -> "LimitedData":
|
|
46
|
+
return cls(
|
|
47
|
+
asset_id=asset_id,
|
|
48
|
+
name=details.get("name", "") if details else "",
|
|
49
|
+
asset_stock=resale.get("assetStock"),
|
|
50
|
+
sales=resale.get("sales", 0),
|
|
51
|
+
number_remaining=resale.get("numberRemaining"),
|
|
52
|
+
recent_average_price=resale.get("recentAveragePrice", 0),
|
|
53
|
+
original_price=resale.get("originalPrice"),
|
|
54
|
+
lowest_resale_price=None,
|
|
55
|
+
price_data_points=resale.get("priceDataPoints", []),
|
|
56
|
+
volume_data_points=resale.get("volumeDataPoints", []),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def price_trend(self) -> str:
|
|
61
|
+
"""'rising', 'falling', or 'stable' based on recent price history."""
|
|
62
|
+
points = [p["value"] for p in self.price_data_points[-5:] if "value" in p]
|
|
63
|
+
if len(points) < 2:
|
|
64
|
+
return "unknown"
|
|
65
|
+
delta = points[-1] - points[0]
|
|
66
|
+
if delta > points[0] * 0.05:
|
|
67
|
+
return "rising"
|
|
68
|
+
if delta < -(points[0] * 0.05):
|
|
69
|
+
return "falling"
|
|
70
|
+
return "stable"
|
|
71
|
+
|
|
72
|
+
def __str__(self) -> str:
|
|
73
|
+
remaining = f"{self.number_remaining:,}" if self.number_remaining is not None else "N/A"
|
|
74
|
+
low = f"{self.lowest_resale_price:,}R$" if self.lowest_resale_price else "N/A"
|
|
75
|
+
emoji = {"rising": "📈", "falling": "📉", "stable": "➡️", "unknown": "❓"}
|
|
76
|
+
return (
|
|
77
|
+
f"💎 {self.name} [#{self.asset_id}]\n"
|
|
78
|
+
f" RAP : {self.recent_average_price:,}R$\n"
|
|
79
|
+
f" Lowest : {low}\n"
|
|
80
|
+
f" Sales : {self.sales:,}\n"
|
|
81
|
+
f" Remaining : {remaining}\n"
|
|
82
|
+
f" Trend : {emoji.get(self.price_trend, '❓')} {self.price_trend}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class ResaleProfit:
|
|
88
|
+
asset_id: int
|
|
89
|
+
purchase_price: int
|
|
90
|
+
rap: int
|
|
91
|
+
lowest_resale: Optional[int]
|
|
92
|
+
roblox_fee: int
|
|
93
|
+
estimated_profit: int
|
|
94
|
+
roi_percent: float
|
|
95
|
+
|
|
96
|
+
def __str__(self) -> str:
|
|
97
|
+
sign = "+" if self.estimated_profit >= 0 else ""
|
|
98
|
+
return (
|
|
99
|
+
f"💰 Asset #{self.asset_id} Profit Estimate\n"
|
|
100
|
+
f" Bought at : {self.purchase_price:,}R$\n"
|
|
101
|
+
f" RAP : {self.rap:,}R$\n"
|
|
102
|
+
f" Roblox fee : -{self.roblox_fee:,}R$ (30%)\n"
|
|
103
|
+
f" Net profit : {sign}{self.estimated_profit:,}R$\n"
|
|
104
|
+
f" ROI : {self.roi_percent:+.1f}%"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class RAPTracker:
|
|
109
|
+
"""
|
|
110
|
+
Tracks RAP changes for a set of limited assets over time.
|
|
111
|
+
|
|
112
|
+
Usage::
|
|
113
|
+
|
|
114
|
+
tracker = RAPTracker(client, [1365767, 1028606])
|
|
115
|
+
tracker.snapshot()
|
|
116
|
+
# ... wait some time ...
|
|
117
|
+
print(tracker.summary())
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, client, asset_ids: List[int]) -> None:
|
|
121
|
+
self._c = client
|
|
122
|
+
self.asset_ids = asset_ids
|
|
123
|
+
self._snapshots: List[Dict[int, int]] = []
|
|
124
|
+
self._timestamps: List[float] = []
|
|
125
|
+
|
|
126
|
+
def snapshot(self) -> Dict[int, int]:
|
|
127
|
+
"""Take a RAP snapshot. Returns {asset_id: rap}."""
|
|
128
|
+
snap: Dict[int, int] = {}
|
|
129
|
+
for aid in self.asset_ids:
|
|
130
|
+
try:
|
|
131
|
+
data = self._c._get(f"https://economy.roblox.com/v1/assets/{aid}/resale-data")
|
|
132
|
+
snap[aid] = data.get("recentAveragePrice", 0)
|
|
133
|
+
except Exception:
|
|
134
|
+
snap[aid] = 0
|
|
135
|
+
self._snapshots.append(snap)
|
|
136
|
+
self._timestamps.append(time.time())
|
|
137
|
+
return snap
|
|
138
|
+
|
|
139
|
+
def diff(self) -> List[dict]:
|
|
140
|
+
"""Compare the last two snapshots."""
|
|
141
|
+
if len(self._snapshots) < 2:
|
|
142
|
+
return []
|
|
143
|
+
old, new = self._snapshots[-2], self._snapshots[-1]
|
|
144
|
+
results = []
|
|
145
|
+
for aid in self.asset_ids:
|
|
146
|
+
o = old.get(aid, 0)
|
|
147
|
+
n = new.get(aid, 0)
|
|
148
|
+
delta = n - o
|
|
149
|
+
pct = ((delta / o) * 100) if o > 0 else 0.0
|
|
150
|
+
results.append({
|
|
151
|
+
"asset_id": aid,
|
|
152
|
+
"old_rap": o,
|
|
153
|
+
"new_rap": n,
|
|
154
|
+
"change": delta,
|
|
155
|
+
"change_pct": round(pct, 2),
|
|
156
|
+
})
|
|
157
|
+
return sorted(results, key=lambda x: abs(x["change"]), reverse=True)
|
|
158
|
+
|
|
159
|
+
def summary(self) -> str:
|
|
160
|
+
"""Formatted summary of the latest diff."""
|
|
161
|
+
changes = self.diff()
|
|
162
|
+
if not changes:
|
|
163
|
+
return "No comparison data yet. Call snapshot() at least twice."
|
|
164
|
+
lines = ["📊 RAP Tracker Summary", "─" * 50]
|
|
165
|
+
for c in changes:
|
|
166
|
+
sign = "+" if c["change"] >= 0 else ""
|
|
167
|
+
arrow = "↑" if c["change"] > 0 else ("↓" if c["change"] < 0 else "→")
|
|
168
|
+
lines.append(
|
|
169
|
+
f" {arrow} Asset #{c['asset_id']:>12} "
|
|
170
|
+
f"| {c['old_rap']:>8,} → {c['new_rap']:>8,}R$ "
|
|
171
|
+
f"| {sign}{c['change']:,}R$ ({sign}{c['change_pct']}%)"
|
|
172
|
+
)
|
|
173
|
+
return "\n".join(lines)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class MarketplaceAPI:
|
|
177
|
+
"""
|
|
178
|
+
Marketplace tools for limited items, economy, and UGC.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
client: A RoboatClient instance.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
ECON = "https://economy.roblox.com/v1"
|
|
185
|
+
CAT = "https://catalog.roblox.com/v1"
|
|
186
|
+
|
|
187
|
+
def __init__(self, client) -> None:
|
|
188
|
+
self._c = client
|
|
189
|
+
|
|
190
|
+
def get_limited_data(self, asset_id: int) -> LimitedData:
|
|
191
|
+
"""Full resale data including price history and lowest listing."""
|
|
192
|
+
resale = self._c._get(f"{self.ECON}/assets/{asset_id}/resale-data")
|
|
193
|
+
try:
|
|
194
|
+
details_resp = self._c._post(
|
|
195
|
+
f"{self.CAT}/catalog/items/details",
|
|
196
|
+
json={"items": [{"itemType": "Asset", "id": asset_id}]},
|
|
197
|
+
)
|
|
198
|
+
details: Optional[dict] = details_resp.get("data", [{}])[0]
|
|
199
|
+
except Exception:
|
|
200
|
+
details = None
|
|
201
|
+
|
|
202
|
+
data = LimitedData.from_resale_dict(asset_id, resale, details)
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
sellers = self._c._get(
|
|
206
|
+
f"{self.ECON}/assets/{asset_id}/resellers", params={"limit": 1}
|
|
207
|
+
)
|
|
208
|
+
first = sellers.get("data", [{}])[0]
|
|
209
|
+
data.lowest_resale_price = first.get("price")
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
return data
|
|
214
|
+
|
|
215
|
+
def get_limited_data_bulk(self, asset_ids: List[int]) -> List[LimitedData]:
|
|
216
|
+
"""Get limited data for multiple assets."""
|
|
217
|
+
return [self.get_limited_data(aid) for aid in asset_ids]
|
|
218
|
+
|
|
219
|
+
def estimate_resale_profit(self, asset_id: int, purchase_price: int) -> ResaleProfit:
|
|
220
|
+
"""Estimate profit from reselling a limited (Roblox takes 30%)."""
|
|
221
|
+
data = self.get_limited_data(asset_id)
|
|
222
|
+
sell_at = data.lowest_resale_price or data.recent_average_price
|
|
223
|
+
fee = int(sell_at * 0.30)
|
|
224
|
+
net = sell_at - fee
|
|
225
|
+
profit = net - purchase_price
|
|
226
|
+
roi = ((profit / purchase_price) * 100) if purchase_price > 0 else 0.0
|
|
227
|
+
return ResaleProfit(
|
|
228
|
+
asset_id=asset_id,
|
|
229
|
+
purchase_price=purchase_price,
|
|
230
|
+
rap=data.recent_average_price,
|
|
231
|
+
lowest_resale=data.lowest_resale_price,
|
|
232
|
+
roblox_fee=fee,
|
|
233
|
+
estimated_profit=profit,
|
|
234
|
+
roi_percent=round(roi, 2),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def create_rap_tracker(self, asset_ids: List[int]) -> RAPTracker:
|
|
238
|
+
"""Create a RAP tracker for a list of limited asset IDs."""
|
|
239
|
+
return RAPTracker(self._c, asset_ids)
|
|
240
|
+
|
|
241
|
+
def find_underpriced_limiteds(
|
|
242
|
+
self, asset_ids: List[int], threshold_pct: float = 0.85
|
|
243
|
+
) -> List[LimitedData]:
|
|
244
|
+
"""
|
|
245
|
+
Find limiteds currently selling below their RAP.
|
|
246
|
+
|
|
247
|
+
Returns items priced below *threshold_pct* × RAP, sorted by discount depth.
|
|
248
|
+
"""
|
|
249
|
+
underpriced: List[LimitedData] = []
|
|
250
|
+
for aid in asset_ids:
|
|
251
|
+
try:
|
|
252
|
+
data = self.get_limited_data(aid)
|
|
253
|
+
if data.lowest_resale_price and data.recent_average_price:
|
|
254
|
+
ratio = data.lowest_resale_price / data.recent_average_price
|
|
255
|
+
if ratio < threshold_pct:
|
|
256
|
+
underpriced.append(data)
|
|
257
|
+
except Exception:
|
|
258
|
+
continue
|
|
259
|
+
return sorted(
|
|
260
|
+
underpriced,
|
|
261
|
+
key=lambda d: (d.lowest_resale_price or 0) / max(d.recent_average_price, 1),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def get_user_purchase_history(
|
|
265
|
+
self,
|
|
266
|
+
user_id: int,
|
|
267
|
+
transaction_type: str = "Purchase",
|
|
268
|
+
limit: int = 25,
|
|
269
|
+
cursor: Optional[str] = None,
|
|
270
|
+
) -> dict:
|
|
271
|
+
"""Get purchase history for a user. Requires auth."""
|
|
272
|
+
self._c.require_auth("get_user_purchase_history")
|
|
273
|
+
params: dict = {"transactionType": transaction_type, "limit": limit}
|
|
274
|
+
if cursor:
|
|
275
|
+
params["cursor"] = cursor
|
|
276
|
+
return self._c._get(
|
|
277
|
+
f"https://economy.roblox.com/v2/users/{user_id}/transactions",
|
|
278
|
+
params=params,
|
|
279
|
+
)
|
roboat_utils/messages.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
roboat.messages
|
|
3
|
+
~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Private Messages API — privatemessages.roblox.com
|
|
5
|
+
All endpoints require authentication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
from roboat_utils.models import Page
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Message:
|
|
16
|
+
id: int
|
|
17
|
+
sender_id: int
|
|
18
|
+
sender_name: str
|
|
19
|
+
recipient_id: int
|
|
20
|
+
recipient_name: str
|
|
21
|
+
subject: str
|
|
22
|
+
body: str
|
|
23
|
+
created: str
|
|
24
|
+
updated: str
|
|
25
|
+
is_read: bool
|
|
26
|
+
is_system_message: bool
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_dict(cls, d: dict) -> "Message":
|
|
30
|
+
sender = d.get("sender", {})
|
|
31
|
+
recipient = d.get("recipient", {})
|
|
32
|
+
return cls(
|
|
33
|
+
id=d.get("id", 0),
|
|
34
|
+
sender_id=sender.get("id", 0),
|
|
35
|
+
sender_name=sender.get("name", ""),
|
|
36
|
+
recipient_id=recipient.get("id", 0),
|
|
37
|
+
recipient_name=recipient.get("name", ""),
|
|
38
|
+
subject=d.get("subject", ""),
|
|
39
|
+
body=d.get("body", ""),
|
|
40
|
+
created=d.get("created", ""),
|
|
41
|
+
updated=d.get("updated", ""),
|
|
42
|
+
is_read=d.get("isRead", False),
|
|
43
|
+
is_system_message=d.get("isSystemMessage", False),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __str__(self) -> str:
|
|
47
|
+
read = "📬" if not self.is_read else "📭"
|
|
48
|
+
return f"{read} [{self.id}] From: {self.sender_name} | {self.subject}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MessagesAPI:
|
|
52
|
+
BASE = "https://privatemessages.roblox.com/v1"
|
|
53
|
+
|
|
54
|
+
def __init__(self, client):
|
|
55
|
+
self._c = client
|
|
56
|
+
|
|
57
|
+
def get_messages(self, message_type: str = "Inbox",
|
|
58
|
+
page_size: int = 25, page_number: int = 0) -> Page:
|
|
59
|
+
"""
|
|
60
|
+
Get private messages. Requires auth.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
message_type: "Inbox", "Sent", "Archive"
|
|
64
|
+
page_size: 10, 20, 25
|
|
65
|
+
page_number: Zero-indexed page number.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Page of Message objects.
|
|
69
|
+
"""
|
|
70
|
+
self._c.require_auth("get_messages")
|
|
71
|
+
data = self._c._get(
|
|
72
|
+
f"{self.BASE}/messages",
|
|
73
|
+
params={
|
|
74
|
+
"messageTab": message_type,
|
|
75
|
+
"pageSize": page_size,
|
|
76
|
+
"pageNumber": page_number,
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
messages = [Message.from_dict(m) for m in data.get("collection", [])]
|
|
80
|
+
return Page(data=messages)
|
|
81
|
+
|
|
82
|
+
def get_message(self, message_id: int) -> Message:
|
|
83
|
+
"""Get a single message by ID. Requires auth."""
|
|
84
|
+
self._c.require_auth("get_message")
|
|
85
|
+
data = self._c._get(f"{self.BASE}/messages/{message_id}")
|
|
86
|
+
return Message.from_dict(data)
|
|
87
|
+
|
|
88
|
+
def send_message(self, recipient_id: int, subject: str, body: str) -> dict:
|
|
89
|
+
"""Send a private message. Requires auth."""
|
|
90
|
+
self._c.require_auth("send_message")
|
|
91
|
+
return self._c._post(
|
|
92
|
+
f"{self.BASE}/messages/send",
|
|
93
|
+
json={
|
|
94
|
+
"recipientId": recipient_id,
|
|
95
|
+
"subject": subject,
|
|
96
|
+
"body": body,
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def get_unread_count(self) -> int:
|
|
101
|
+
"""Get count of unread messages. Requires auth."""
|
|
102
|
+
self._c.require_auth("get_unread_count")
|
|
103
|
+
data = self._c._get(f"{self.BASE}/messages/unread/count")
|
|
104
|
+
return data.get("count", 0)
|
|
105
|
+
|
|
106
|
+
def mark_read(self, message_ids: List[int]) -> None:
|
|
107
|
+
"""Mark messages as read. Requires auth."""
|
|
108
|
+
self._c.require_auth("mark_read")
|
|
109
|
+
self._c._post(
|
|
110
|
+
f"{self.BASE}/messages/mark-read",
|
|
111
|
+
json={"messageIds": message_ids},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def mark_unread(self, message_ids: List[int]) -> None:
|
|
115
|
+
"""Mark messages as unread. Requires auth."""
|
|
116
|
+
self._c.require_auth("mark_unread")
|
|
117
|
+
self._c._post(
|
|
118
|
+
f"{self.BASE}/messages/mark-unread",
|
|
119
|
+
json={"messageIds": message_ids},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def archive(self, message_ids: List[int]) -> None:
|
|
123
|
+
"""Archive messages. Requires auth."""
|
|
124
|
+
self._c.require_auth("archive")
|
|
125
|
+
self._c._post(
|
|
126
|
+
f"{self.BASE}/messages/archive",
|
|
127
|
+
json={"messageIds": message_ids},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class ChatConversation:
|
|
133
|
+
id: int
|
|
134
|
+
title: str
|
|
135
|
+
conversation_type: str
|
|
136
|
+
participants: List[dict] = field(default_factory=list)
|
|
137
|
+
last_updated: Optional[str] = None
|
|
138
|
+
unread_count: int = 0
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def from_dict(cls, d: dict) -> "ChatConversation":
|
|
142
|
+
return cls(
|
|
143
|
+
id=d.get("id", 0),
|
|
144
|
+
title=d.get("title", ""),
|
|
145
|
+
conversation_type=d.get("conversationType", ""),
|
|
146
|
+
participants=d.get("participants", []),
|
|
147
|
+
last_updated=d.get("lastUpdated"),
|
|
148
|
+
unread_count=d.get("unreadMessageCount", 0),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def __str__(self) -> str:
|
|
152
|
+
unread = f" [{self.unread_count} unread]" if self.unread_count else ""
|
|
153
|
+
return f"💬 {self.title}{unread}"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class ChatAPI:
|
|
157
|
+
BASE = "https://chat.roblox.com/v2"
|
|
158
|
+
|
|
159
|
+
def __init__(self, client):
|
|
160
|
+
self._c = client
|
|
161
|
+
|
|
162
|
+
def get_conversations(self, page_size: int = 30,
|
|
163
|
+
page_number: int = 1) -> List[ChatConversation]:
|
|
164
|
+
"""Get chat conversations. Requires auth."""
|
|
165
|
+
self._c.require_auth("get_conversations")
|
|
166
|
+
data = self._c._get(
|
|
167
|
+
f"{self.BASE}/get-user-conversations",
|
|
168
|
+
params={"pageSize": page_size, "pageNumber": page_number},
|
|
169
|
+
)
|
|
170
|
+
return [ChatConversation.from_dict(c) for c in (data if isinstance(data, list) else [])]
|
|
171
|
+
|
|
172
|
+
def get_unread_conversation_count(self) -> int:
|
|
173
|
+
"""Get count of conversations with unread messages. Requires auth."""
|
|
174
|
+
self._c.require_auth("get_unread_conversation_count")
|
|
175
|
+
data = self._c._get(f"{self.BASE}/get-unread-conversation-count")
|
|
176
|
+
return data.get("count", 0)
|
|
177
|
+
|
|
178
|
+
def get_messages(self, conversation_id: int,
|
|
179
|
+
page_size: int = 30) -> list:
|
|
180
|
+
"""Get messages in a conversation. Requires auth."""
|
|
181
|
+
self._c.require_auth("get_messages")
|
|
182
|
+
data = self._c._get(
|
|
183
|
+
f"{self.BASE}/get-messages",
|
|
184
|
+
params={"conversationId": conversation_id, "pageSize": page_size},
|
|
185
|
+
)
|
|
186
|
+
return data if isinstance(data, list) else []
|
|
187
|
+
|
|
188
|
+
def send_message(self, conversation_id: int, message: str) -> dict:
|
|
189
|
+
"""Send a chat message. Requires auth."""
|
|
190
|
+
self._c.require_auth("send_message")
|
|
191
|
+
return self._c._post(
|
|
192
|
+
f"{self.BASE}/send-message",
|
|
193
|
+
json={"conversationId": conversation_id, "message": message},
|
|
194
|
+
)
|