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:
Joe 2026-03-01 20:03:40 -06:00
parent fb46515913
commit 9934094dbc
22 changed files with 2427 additions and 161 deletions

View file

@ -0,0 +1,251 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING
import anthropic
import discord
from . import atlas_cli, discord_fmt, review, security
if TYPE_CHECKING:
from .config import Config
from .db import Database, Game
log = logging.getLogger(__name__)
_sync_tasks: dict[int, asyncio.Task[None]] = {}
async def init_game(
config: Config, place_id: int, server_id: str, opencloud_key: str
) -> Path:
game_dir = config.data_dir / server_id / str(place_id)
game_dir.mkdir(parents=True, exist_ok=True)
await atlas_cli.atlas_clone(config.atlas_binary, place_id, game_dir, opencloud_key)
await atlas_cli.git_init(game_dir)
await atlas_cli.git_add_all(game_dir)
await atlas_cli.git_commit(game_dir, "Initial sync")
return game_dir
def start_sync_loop(
game: Game,
config: Config,
db: Database,
bot: discord.Client,
claude: anthropic.AsyncAnthropic,
) -> None:
if game.id in _sync_tasks and not _sync_tasks[game.id].done():
return
task = asyncio.create_task(
_sync_loop(game, config, db, bot, claude), name=f"sync-{game.place_id}"
)
_sync_tasks[game.id] = task
def stop_sync_loop(game_id: int) -> None:
task = _sync_tasks.pop(game_id, None)
if task and not task.done():
task.cancel()
def stop_all_for_server(db: Database, server_id: str) -> None:
for game in db.get_games_for_server(server_id):
stop_sync_loop(game.id)
async def _sync_loop(
game: Game,
config: Config,
db: Database,
bot: discord.Client,
claude: anthropic.AsyncAnthropic,
) -> None:
await bot.wait_until_ready()
log.info("Starting sync loop for PlaceId %d (server %s)", game.place_id, game.discord_server_id)
while True:
try:
api_key = security.decrypt_api_key(game.api_key_encrypted)
working_dir = Path(game.working_dir)
await atlas_cli.atlas_syncback(
config.atlas_binary, working_dir, game.place_id, api_key
)
diff = await atlas_cli.git_diff(working_dir)
if diff:
changed_files = await atlas_cli.git_diff_name_only(working_dir)
script_files = [
f for f in changed_files
if any(f.endswith(ext) for ext in review.REVIEWABLE_EXTENSIONS)
]
auto_resolved_ids = _auto_resolve(db, game.id, changed_files)
for rid in auto_resolved_ids:
await _update_resolved_embed(bot, db, rid)
if script_files:
existing = db.get_unresolved_issues(game.id)
try:
issues = await review.review_diff(claude, diff, existing)
except Exception as exc:
_handle_error(db, bot, game, str(exc), "Claude API error")
issues = []
new_issues = [
i for i in issues
if not db.final_dedup(game.id, i.file, i.title)
]
if new_issues:
await _post_issues(bot, db, game, new_issues, diff, changed_files)
await atlas_cli.git_add_all(working_dir)
await atlas_cli.git_commit(
working_dir,
f"Sync {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
)
db.update_last_sync(game.id)
except asyncio.CancelledError:
log.info("Sync loop cancelled for PlaceId %d", game.place_id)
return
except Exception as exc:
error_str = str(exc)
error_type = "Syncback error"
if "HTTP 403" in error_str or "HTTP 401" in error_str:
error_type = "Download failure (auth)"
elif "Failed to download" in error_str:
error_type = "Download failure"
elif "atlas syncback failed" in error_str:
error_type = "Syncback crash"
elif "git" in error_str.lower():
error_type = "Git error"
should_report = db.update_last_error(game.id, error_str)
if should_report:
await _send_error(bot, game, error_str, error_type)
log.exception("Sync loop error for PlaceId %d", game.place_id)
await asyncio.sleep(config.sync_interval)
def _auto_resolve(db: Database, game_id: int, changed_files: list[str]) -> list[int]:
resolved_ids = []
for issue in db.get_unresolved_issues(game_id):
if issue.file_path in changed_files:
db.resolve_issue(issue.id, resolved_by="auto", reason="file modified")
resolved_ids.append(issue.id)
return resolved_ids
async def _update_resolved_embed(bot: discord.Client, db: Database, issue_id: int) -> None:
from .db import DBIssue
row = db._conn.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)).fetchone()
if not row:
return
issue = DBIssue(*row)
if not issue.discord_message_id:
return
try:
for guild in bot.guilds:
for channel in guild.text_channels:
try:
msg = await channel.fetch_message(int(issue.discord_message_id))
if msg.embeds:
embed = msg.embeds[0]
embed.set_footer(text="Resolved -- code was modified")
embed.color = 0x95A5A6
await msg.edit(embed=embed, view=None)
return
except (discord.NotFound, discord.Forbidden):
continue
except Exception:
log.debug("Could not update resolved embed for issue %d", issue_id)
async def _post_issues(
bot: discord.Client,
db: Database,
game: Game,
issues: list[review.Issue],
diff: str,
changed_files: list[str],
) -> None:
channel = bot.get_channel(int(game.channel_id))
if not channel or not isinstance(channel, discord.TextChannel):
log.warning("Channel %s not found for PlaceId %d", game.channel_id, game.place_id)
return
lines_added, lines_removed = discord_fmt.count_diff_lines(diff)
script_files = [
f for f in changed_files
if any(f.endswith(ext) for ext in review.REVIEWABLE_EXTENSIONS)
]
auto_resolved_count = len([
i for i in db.get_unresolved_issues(game.id)
])
summary = discord_fmt.make_summary_embed(
game.place_id, issues, 0, script_files, lines_added, lines_removed
)
has_critical = any(i.severity == "Critical" for i in issues)
content = "@here" if has_critical else None
await channel.send(content=content, embed=summary)
for issue in issues:
embed = discord_fmt.make_issue_embed(issue)
issue_db_id = db.add_issue(
game_id=game.id,
discord_message_id=None,
file_path=issue.file,
line_start=issue.line_start,
line_end=issue.line_end,
severity=issue.severity,
title=issue.title,
explanation=issue.explanation,
suggestion=issue.suggestion,
)
view = discord_fmt.IssueButtons(issue_db_id)
msg = await channel.send(embed=embed, view=view)
db.update_issue_message_id(issue_db_id, str(msg.id))
async def _send_error(
bot: discord.Client, game: Game, error: str, error_type: str
) -> None:
channel = bot.get_channel(int(game.channel_id))
if not channel or not isinstance(channel, discord.TextChannel):
return
embed = discord_fmt.make_error_embed(
game.place_id, error, error_type, game.last_sync_at
)
await channel.send(embed=embed)
def _handle_error(
db: Database,
bot: discord.Client,
game: Game,
error: str,
error_type: str,
) -> None:
should_report = db.update_last_error(game.id, error)
if should_report:
asyncio.create_task(_send_error(bot, game, error, error_type))