mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 22:11:38 +00:00
- 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.
251 lines
8 KiB
Python
251 lines
8 KiB
Python
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))
|