mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 14:01:37 +00:00
Enhance Roblox API integration and refactor CLI commands
- Updated the `clap` dependency in `Cargo.toml` to include the "env" feature for improved command-line argument handling. - Introduced a new `roblox_api` module to encapsulate Roblox API interactions, including authentication and asset downloading. - Refactored the `CloneCommand` and `StudioCommand` to utilize the new `roblox_api` functions for fetching experience names and universe IDs, enhancing code clarity and reducing duplication. - Removed obsolete functions related to Roblox API calls from the CLI commands, streamlining the codebase. - Added error handling for authentication failures, ensuring better user feedback when required credentials are missing. These changes aim to improve the overall structure and functionality of the CLI while enhancing the integration with the Roblox API.
This commit is contained in:
parent
fb46515913
commit
9934094dbc
22 changed files with 2427 additions and 161 deletions
|
|
@ -2,9 +2,8 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use anyhow::{bail, Context};
|
||||
use clap::Parser;
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::COOKIE;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::roblox_api;
|
||||
|
||||
use super::init::{InitCommand, InitKind};
|
||||
use super::syncback::SyncbackCommand;
|
||||
|
|
@ -46,7 +45,15 @@ impl CloneCommand {
|
|||
let path = match self.path {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let name = fetch_experience_name(self.placeid)?;
|
||||
let auth = roblox_api::try_resolve_auth(global.opencloud.as_deref());
|
||||
let name = match auth {
|
||||
Some(a) => roblox_api::fetch_experience_name(self.placeid, &a)?
|
||||
.context("Could not fetch experience name from Roblox API")?,
|
||||
None => bail!(
|
||||
"No --opencloud API key or Roblox cookie available to fetch experience name. \
|
||||
Use --path to specify a folder manually, or pass --opencloud <KEY>."
|
||||
),
|
||||
};
|
||||
let folder = sanitize_name(&name);
|
||||
if folder.is_empty() {
|
||||
bail!(
|
||||
|
|
@ -107,66 +114,6 @@ impl CloneCommand {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UniverseResponse {
|
||||
#[serde(rename = "universeId")]
|
||||
universe_id: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GamesResponse {
|
||||
data: Vec<GameData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GameData {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Resolves a place ID to its experience name via the Roblox API.
|
||||
fn fetch_experience_name(place_id: u64) -> anyhow::Result<String> {
|
||||
let cookie_header = rbx_cookie::get_value().map(|c| format!(".ROBLOSECURITY={}", c));
|
||||
|
||||
let client = Client::new();
|
||||
|
||||
// Place ID -> Universe ID
|
||||
let universe_url = format!("https://apis.roblox.com/universes/v1/places/{place_id}/universe");
|
||||
let mut req = client.get(&universe_url);
|
||||
if let Some(cookie) = &cookie_header {
|
||||
req = req.header(COOKIE, cookie);
|
||||
}
|
||||
let universe: UniverseResponse = req
|
||||
.send()
|
||||
.context("Failed to reach Roblox universe API")?
|
||||
.json()
|
||||
.context("Failed to parse universe response")?;
|
||||
|
||||
// Universe ID -> Game info
|
||||
let games_url = format!(
|
||||
"https://games.roblox.com/v1/games?universeIds={}",
|
||||
universe.universe_id
|
||||
);
|
||||
let mut req = client.get(&games_url);
|
||||
if let Some(cookie) = &cookie_header {
|
||||
req = req.header(COOKIE, cookie);
|
||||
}
|
||||
let games: GamesResponse = req
|
||||
.send()
|
||||
.context("Failed to reach Roblox games API")?
|
||||
.json()
|
||||
.context("Failed to parse games response")?;
|
||||
|
||||
let game = games
|
||||
.data
|
||||
.into_iter()
|
||||
.next()
|
||||
.context("No game data returned from Roblox")?;
|
||||
|
||||
println!("Experience: {}", game.name);
|
||||
|
||||
Ok(game.name)
|
||||
}
|
||||
|
||||
/// Sanitize a Roblox experience name into a valid folder name.
|
||||
///
|
||||
/// 1. Strip `[...]` and `(...)` sections
|
||||
|
|
|
|||
|
|
@ -58,13 +58,13 @@ impl Options {
|
|||
Subcommand::Init(subcommand) => subcommand.run(),
|
||||
Subcommand::Serve(subcommand) => subcommand.run(),
|
||||
Subcommand::Build(subcommand) => subcommand.run(),
|
||||
Subcommand::Upload(subcommand) => subcommand.run(),
|
||||
Subcommand::Upload(subcommand) => subcommand.run(self.global),
|
||||
Subcommand::Sourcemap(subcommand) => subcommand.run(),
|
||||
Subcommand::FmtProject(subcommand) => subcommand.run(),
|
||||
Subcommand::Cursor(subcommand) => subcommand.run(),
|
||||
Subcommand::Doc(subcommand) => subcommand.run(),
|
||||
Subcommand::Plugin(subcommand) => subcommand.run(),
|
||||
Subcommand::Studio(subcommand) => subcommand.run(),
|
||||
Subcommand::Studio(subcommand) => subcommand.run(self.global),
|
||||
Subcommand::Syncback(subcommand) | Subcommand::Pull(subcommand) => {
|
||||
subcommand.run(self.global)
|
||||
}
|
||||
|
|
@ -81,6 +81,11 @@ pub struct GlobalOptions {
|
|||
/// Set color behavior. Valid values are auto, always, and never.
|
||||
#[clap(long("color"), global(true), default_value("auto"))]
|
||||
pub color: ColorChoice,
|
||||
|
||||
/// OpenCloud API key for Roblox API access (alternative to cookie auth).
|
||||
/// Requires the 'legacy-asset:manage' scope for downloading places.
|
||||
#[clap(long, env = "ATLAS_OPENCLOUD_KEY", global(true), hide = true)]
|
||||
pub opencloud: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
|
|
|
|||
|
|
@ -5,19 +5,12 @@ use std::process::Command;
|
|||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use memofs::Vfs;
|
||||
use reqwest::header::COOKIE;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::project::Project;
|
||||
use crate::roblox_api;
|
||||
|
||||
use super::resolve_path;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UniverseResponse {
|
||||
#[serde(rename = "universeId")]
|
||||
universe_id: u64,
|
||||
}
|
||||
|
||||
/// Open a Rojo project in Roblox Studio.
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct StudioCommand {
|
||||
|
|
@ -27,7 +20,7 @@ pub struct StudioCommand {
|
|||
}
|
||||
|
||||
impl StudioCommand {
|
||||
pub fn run(self) -> anyhow::Result<()> {
|
||||
pub fn run(self, global: super::GlobalOptions) -> anyhow::Result<()> {
|
||||
let vfs = Vfs::new_oneshot();
|
||||
|
||||
let base_path = resolve_path(&self.project);
|
||||
|
|
@ -45,14 +38,17 @@ impl StudioCommand {
|
|||
.copied()
|
||||
.context("servePlaceIds is empty in project file")?;
|
||||
|
||||
let universe_id = get_universe_id(place_id)?;
|
||||
let auth = roblox_api::try_resolve_auth(global.opencloud.as_deref());
|
||||
let universe_id = match auth {
|
||||
Some(a) => roblox_api::get_universe_id(place_id, &a)?,
|
||||
None => anyhow::bail!("No Roblox auth cookie found. Please log into Roblox Studio."),
|
||||
};
|
||||
|
||||
let url = format!(
|
||||
"roblox-studio:1+launchmode:edit+task:EditPlace+placeId:{}+universeId:{}",
|
||||
place_id, universe_id
|
||||
);
|
||||
|
||||
// Use cmd /c start to fully detach the process on Windows
|
||||
#[cfg(windows)]
|
||||
Command::new("cmd")
|
||||
.args(["/c", "start", "", &url])
|
||||
|
|
@ -65,24 +61,3 @@ impl StudioCommand {
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_universe_id(place_id: u64) -> anyhow::Result<u64> {
|
||||
let cookie_header = rbx_cookie::get_value().map(|c| format!(".ROBLOSECURITY={}", c));
|
||||
|
||||
let url = format!(
|
||||
"https://apis.roblox.com/universes/v1/places/{}/universe",
|
||||
place_id
|
||||
);
|
||||
|
||||
let mut req = reqwest::blocking::Client::new().get(&url);
|
||||
if let Some(cookie) = &cookie_header {
|
||||
req = req.header(COOKIE, cookie);
|
||||
}
|
||||
let response: UniverseResponse = req
|
||||
.send()
|
||||
.context("Failed to fetch universe ID from Roblox API")?
|
||||
.json()
|
||||
.context("Failed to parse universe ID response")?;
|
||||
|
||||
Ok(response.universe_id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,18 +5,18 @@ use std::{
|
|||
time::Instant,
|
||||
};
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use fs_err::File;
|
||||
use memofs::Vfs;
|
||||
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
||||
use reqwest::header::{CACHE_CONTROL, COOKIE, PRAGMA, USER_AGENT};
|
||||
use tempfile::NamedTempFile;
|
||||
use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor};
|
||||
|
||||
use crate::{
|
||||
path_serializer::display_absolute,
|
||||
project::Project,
|
||||
roblox_api,
|
||||
serve_session::ServeSession,
|
||||
syncback::{syncback_loop, FsSnapshot},
|
||||
};
|
||||
|
|
@ -109,7 +109,8 @@ impl SyncbackCommand {
|
|||
// --download=PLACEID: always download this specific place
|
||||
log::info!("Downloading place {}...", place_id);
|
||||
let download_timer = Instant::now();
|
||||
let temp = download_place(*place_id)?;
|
||||
let auth = roblox_api::resolve_auth(global.opencloud.as_deref())?;
|
||||
let temp = roblox_api::download_place(*place_id, &auth)?;
|
||||
log::info!(
|
||||
"Downloaded in {:.02}s",
|
||||
download_timer.elapsed().as_secs_f32()
|
||||
|
|
@ -139,7 +140,8 @@ impl SyncbackCommand {
|
|||
place_id
|
||||
);
|
||||
let download_timer = Instant::now();
|
||||
let temp = download_place(place_id)?;
|
||||
let auth = roblox_api::resolve_auth(global.opencloud.as_deref())?;
|
||||
let temp = roblox_api::download_place(place_id, &auth)?;
|
||||
log::info!(
|
||||
"Downloaded in {:.02}s",
|
||||
download_timer.elapsed().as_secs_f32()
|
||||
|
|
@ -335,61 +337,6 @@ fn get_place_id_from_project(project_path: &Path) -> anyhow::Result<u64> {
|
|||
.context("servePlaceIds is empty in project file")
|
||||
}
|
||||
|
||||
/// Downloads a place file from Roblox's asset delivery API.
|
||||
///
|
||||
/// Uses rbx_cookie to get the authentication cookie from the system.
|
||||
fn download_place(place_id: u64) -> anyhow::Result<NamedTempFile> {
|
||||
let cookie = rbx_cookie::get_value()
|
||||
.context("Could not find Roblox authentication cookie. Please log into Roblox Studio.")?;
|
||||
|
||||
let url = format!("https://assetdelivery.roblox.com/v1/asset/?id={}", place_id);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.gzip(true)
|
||||
.brotli(true)
|
||||
.deflate(true)
|
||||
.build()?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header(COOKIE, format!(".ROBLOSECURITY={}", cookie))
|
||||
.header(CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
||||
.header(PRAGMA, "no-cache")
|
||||
.header("Expires", "0")
|
||||
.header(USER_AGENT, "Rojo")
|
||||
.send()?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
bail!(
|
||||
"Failed to download place {}: HTTP {} - {}",
|
||||
place_id,
|
||||
status,
|
||||
response.text().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
// Create temp file with .rbxl extension
|
||||
let mut temp_file = tempfile::Builder::new()
|
||||
.prefix("rojo-syncback-")
|
||||
.suffix(".rbxl")
|
||||
.tempfile()
|
||||
.context("Failed to create temporary file")?;
|
||||
|
||||
// Write response body to temp file
|
||||
let bytes = response.bytes()?;
|
||||
io::copy(&mut bytes.as_ref(), &mut temp_file)?;
|
||||
temp_file.flush()?;
|
||||
|
||||
log::debug!(
|
||||
"Downloaded {} bytes to {}",
|
||||
bytes.len(),
|
||||
temp_file.path().display()
|
||||
);
|
||||
|
||||
Ok(temp_file)
|
||||
}
|
||||
|
||||
fn read_dom(path: &Path, file_kind: FileKind) -> anyhow::Result<WeakDom> {
|
||||
let content = BufReader::new(File::open(path)?);
|
||||
match file_kind {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use serde_json::Value;
|
|||
|
||||
use crate::serve_session::ServeSession;
|
||||
|
||||
use super::resolve_path;
|
||||
use super::{resolve_path, GlobalOptions};
|
||||
|
||||
const ASSETS_API_BASE: &str = "https://apis.roblox.com/assets/v1";
|
||||
const MAX_OPERATION_RETRIES: u32 = 10;
|
||||
|
|
@ -50,7 +50,7 @@ pub struct UploadCommand {
|
|||
}
|
||||
|
||||
impl UploadCommand {
|
||||
pub fn run(self) -> Result<(), anyhow::Error> {
|
||||
pub fn run(self, global: GlobalOptions) -> Result<(), anyhow::Error> {
|
||||
let project_path = resolve_path(&self.project);
|
||||
|
||||
let vfs = Vfs::new_default();
|
||||
|
|
@ -71,7 +71,9 @@ impl UploadCommand {
|
|||
log::trace!("Encoding binary model");
|
||||
rbx_binary::to_writer(&mut buffer, tree.inner(), &encode_ids)?;
|
||||
|
||||
match (self.cookie, self.api_key, self.universe_id) {
|
||||
let api_key = self.api_key.or(global.opencloud);
|
||||
|
||||
match (self.cookie, api_key, self.universe_id) {
|
||||
(cookie, None, universe) => {
|
||||
// Legacy cookie auth
|
||||
if universe.is_some() {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ mod multimap;
|
|||
mod path_serializer;
|
||||
mod project;
|
||||
mod resolution;
|
||||
pub mod roblox_api;
|
||||
mod rojo_ref;
|
||||
mod serve_session;
|
||||
mod session_id;
|
||||
|
|
|
|||
261
src/roblox_api.rs
Normal file
261
src/roblox_api.rs
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
use std::io::{self, Write as _};
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use reqwest::header::{CACHE_CONTROL, COOKIE, PRAGMA, USER_AGENT};
|
||||
use serde::Deserialize;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
/// Authentication method for Roblox API calls.
|
||||
pub enum RobloxAuth {
|
||||
Cookie(String),
|
||||
ApiKey(String),
|
||||
}
|
||||
|
||||
/// Resolve authentication: prefer `--opencloud` API key, fall back to system cookie.
|
||||
pub fn resolve_auth(opencloud_key: Option<&str>) -> anyhow::Result<RobloxAuth> {
|
||||
if let Some(key) = opencloud_key {
|
||||
return Ok(RobloxAuth::ApiKey(key.to_string()));
|
||||
}
|
||||
|
||||
let cookie = rbx_cookie::get_value().context(
|
||||
"No OpenCloud API key (--opencloud) or Roblox auth cookie found. \
|
||||
Pass --opencloud <KEY> or log into Roblox Studio.",
|
||||
)?;
|
||||
Ok(RobloxAuth::Cookie(cookie))
|
||||
}
|
||||
|
||||
/// Try to resolve authentication, returning `None` if neither key nor cookie is available.
|
||||
pub fn try_resolve_auth(opencloud_key: Option<&str>) -> Option<RobloxAuth> {
|
||||
if let Some(key) = opencloud_key {
|
||||
return Some(RobloxAuth::ApiKey(key.to_string()));
|
||||
}
|
||||
rbx_cookie::get_value().map(RobloxAuth::Cookie)
|
||||
}
|
||||
|
||||
/// Download a place file from Roblox.
|
||||
///
|
||||
/// With API key: uses OpenCloud Asset Delivery API (`apis.roblox.com/asset-delivery-api`).
|
||||
/// With cookie: uses legacy asset delivery (`assetdelivery.roblox.com`).
|
||||
pub fn download_place(place_id: u64, auth: &RobloxAuth) -> anyhow::Result<NamedTempFile> {
|
||||
let bytes = match auth {
|
||||
RobloxAuth::ApiKey(key) => download_place_opencloud(place_id, key)?,
|
||||
RobloxAuth::Cookie(cookie) => download_place_cookie(place_id, cookie)?,
|
||||
};
|
||||
|
||||
let mut temp_file = tempfile::Builder::new()
|
||||
.prefix("rojo-syncback-")
|
||||
.suffix(".rbxl")
|
||||
.tempfile()
|
||||
.context("Failed to create temporary file")?;
|
||||
|
||||
io::copy(&mut bytes.as_slice(), &mut temp_file)?;
|
||||
temp_file.flush()?;
|
||||
|
||||
log::debug!(
|
||||
"Downloaded {} bytes to {}",
|
||||
bytes.len(),
|
||||
temp_file.path().display()
|
||||
);
|
||||
|
||||
Ok(temp_file)
|
||||
}
|
||||
|
||||
fn download_place_opencloud(place_id: u64, api_key: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let url = format!(
|
||||
"https://apis.roblox.com/asset-delivery-api/v1/assetId/{}",
|
||||
place_id
|
||||
);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.gzip(true)
|
||||
.brotli(true)
|
||||
.deflate(true)
|
||||
.build()?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("x-api-key", api_key)
|
||||
.header("Accept-Encoding", "gzip")
|
||||
.send()?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
bail!(
|
||||
"Failed to download place {} via OpenCloud: HTTP {} - {}",
|
||||
place_id,
|
||||
status,
|
||||
response.text().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
let body: AssetDeliveryResponse = response.json().with_context(|| {
|
||||
format!(
|
||||
"Failed to parse OpenCloud asset delivery response for place {}",
|
||||
place_id
|
||||
)
|
||||
})?;
|
||||
|
||||
let cdn_url = body.location.with_context(|| {
|
||||
format!(
|
||||
"OpenCloud asset delivery response for place {} has no location URL. \
|
||||
Ensure the API key has the 'legacy-asset:manage' scope.",
|
||||
place_id
|
||||
)
|
||||
})?;
|
||||
|
||||
let cdn_response = client.get(&cdn_url).send()?;
|
||||
|
||||
let cdn_status = cdn_response.status();
|
||||
if !cdn_status.is_success() {
|
||||
bail!(
|
||||
"Failed to download place {} from CDN: HTTP {} - {}",
|
||||
place_id,
|
||||
cdn_status,
|
||||
cdn_response.text().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(cdn_response.bytes()?.to_vec())
|
||||
}
|
||||
|
||||
fn download_place_cookie(place_id: u64, cookie: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let url = format!("https://assetdelivery.roblox.com/v1/asset/?id={}", place_id);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.gzip(true)
|
||||
.brotli(true)
|
||||
.deflate(true)
|
||||
.build()?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header(COOKIE, format!(".ROBLOSECURITY={}", cookie))
|
||||
.header(CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
||||
.header(PRAGMA, "no-cache")
|
||||
.header("Expires", "0")
|
||||
.header(USER_AGENT, "Rojo")
|
||||
.send()?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
bail!(
|
||||
"Failed to download place {}: HTTP {} - {}",
|
||||
place_id,
|
||||
status,
|
||||
response.text().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(response.bytes()?.to_vec())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AssetDeliveryResponse {
|
||||
location: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UniverseResponse {
|
||||
#[serde(rename = "universeId")]
|
||||
universe_id: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GamesResponse {
|
||||
data: Vec<GameData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GameData {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Resolve a place ID to a universe ID.
|
||||
///
|
||||
/// Returns `None` if no auth is available. Works with both API key and cookie.
|
||||
pub fn get_universe_id(place_id: u64, auth: &RobloxAuth) -> anyhow::Result<u64> {
|
||||
let url = format!(
|
||||
"https://apis.roblox.com/universes/v1/places/{}/universe",
|
||||
place_id
|
||||
);
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let req = apply_auth(client.get(&url), auth);
|
||||
|
||||
let response: UniverseResponse = req
|
||||
.send()
|
||||
.context("Failed to fetch universe ID from Roblox API")?
|
||||
.json()
|
||||
.context("Failed to parse universe ID response")?;
|
||||
|
||||
Ok(response.universe_id)
|
||||
}
|
||||
|
||||
/// Resolve a place ID to its experience name via the Roblox API.
|
||||
///
|
||||
/// With cookie auth, uses both the universe API and the games API.
|
||||
/// With API key, the games.roblox.com endpoint may not support API key auth,
|
||||
/// so this falls back gracefully.
|
||||
pub fn fetch_experience_name(place_id: u64, auth: &RobloxAuth) -> anyhow::Result<Option<String>> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
||||
let universe_url = format!(
|
||||
"https://apis.roblox.com/universes/v1/places/{}/universe",
|
||||
place_id
|
||||
);
|
||||
let req = apply_auth(client.get(&universe_url), auth);
|
||||
let universe: UniverseResponse = req
|
||||
.send()
|
||||
.context("Failed to reach Roblox universe API")?
|
||||
.json()
|
||||
.context("Failed to parse universe response")?;
|
||||
|
||||
let games_url = format!(
|
||||
"https://games.roblox.com/v1/games?universeIds={}",
|
||||
universe.universe_id
|
||||
);
|
||||
|
||||
// games.roblox.com may not support API key auth; try with auth, fall back to no-auth
|
||||
let req = apply_auth(client.get(&games_url), auth);
|
||||
let games_result: Result<GamesResponse, _> = req.send().and_then(|r| r.json());
|
||||
|
||||
match games_result {
|
||||
Ok(games) => {
|
||||
if let Some(game) = games.data.into_iter().next() {
|
||||
println!("Experience: {}", game.name);
|
||||
return Ok(Some(game.name));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Could not fetch game name (games API may not support this auth method): {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If API key was used and games endpoint failed, try without auth
|
||||
if matches!(auth, RobloxAuth::ApiKey(_)) {
|
||||
let req = client.get(&games_url);
|
||||
if let Ok(resp) = req.send() {
|
||||
if let Ok(games) = resp.json::<GamesResponse>() {
|
||||
if let Some(game) = games.data.into_iter().next() {
|
||||
println!("Experience: {}", game.name);
|
||||
return Ok(Some(game.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn apply_auth(
|
||||
req: reqwest::blocking::RequestBuilder,
|
||||
auth: &RobloxAuth,
|
||||
) -> reqwest::blocking::RequestBuilder {
|
||||
match auth {
|
||||
RobloxAuth::ApiKey(key) => req.header("x-api-key", key),
|
||||
RobloxAuth::Cookie(cookie) => req.header(COOKIE, format!(".ROBLOSECURITY={}", cookie)),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue