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,13 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .db import Database
class _BotRef:
db: Database | None = None
_bot_ref = _BotRef()

View file

@ -0,0 +1,67 @@
from __future__ import annotations
import logging
import sys
import click
from .bot import AtlasDaemonBot
from .config import Config
from .db import Database
from .security import init_encryption
@click.command()
@click.option("--discord-token", envvar="ATLAS_DAEMON_DISCORD_TOKEN", default="", help="Discord bot token")
@click.option("--anthropic-key", envvar="ATLAS_DAEMON_ANTHROPIC_KEY", default="", help="Anthropic API key")
@click.option("--data-dir", envvar="ATLAS_DAEMON_DATA_DIR", default="./atlas-daemon-data", help="Data directory")
@click.option("--encryption-key", envvar="ATLAS_DAEMON_ENCRYPTION_KEY", default="", help="Fernet encryption key")
@click.option("--sync-interval", envvar="ATLAS_DAEMON_SYNC_INTERVAL", default=300, type=int, help="Sync interval (seconds)")
@click.option("--atlas-binary", envvar="ATLAS_DAEMON_ATLAS_BINARY", default="atlas", help="Path to atlas binary")
def main(
discord_token: str,
anthropic_key: str,
data_dir: str,
encryption_key: str,
sync_interval: int,
atlas_binary: str,
) -> None:
"""Atlas Daemon - Automated Roblox Code Review Bot"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
if not discord_token:
click.echo("Error: Discord token is required (--discord-token or ATLAS_DAEMON_DISCORD_TOKEN)", err=True)
sys.exit(1)
if not anthropic_key:
click.echo("Error: Anthropic key is required (--anthropic-key or ATLAS_DAEMON_ANTHROPIC_KEY)", err=True)
sys.exit(1)
config = Config.from_env(
discord_token=discord_token,
anthropic_key=anthropic_key,
data_dir=data_dir,
encryption_key=encryption_key,
sync_interval=sync_interval,
atlas_binary=atlas_binary,
)
config.data_dir.mkdir(parents=True, exist_ok=True)
init_encryption(config.encryption_key)
db = Database(config.data_dir / "atlas-daemon.db")
import anthropic as anthropic_mod
claude = anthropic_mod.AsyncAnthropic(api_key=config.anthropic_key)
bot = AtlasDaemonBot(config, db, claude)
bot.run(config.discord_token)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,101 @@
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
log = logging.getLogger(__name__)
async def _run(
*cmd: str, cwd: Path | None = None, timeout: float = 300
) -> tuple[str, str]:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(cwd) if cwd else None,
)
stdout_bytes, stderr_bytes = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
stdout = stdout_bytes.decode(errors="replace") if stdout_bytes else ""
stderr = stderr_bytes.decode(errors="replace") if stderr_bytes else ""
if proc.returncode != 0:
raise RuntimeError(
f"Command failed (exit {proc.returncode}): {' '.join(cmd)}\n{stderr}"
)
return stdout, stderr
async def atlas_clone(
atlas_binary: str,
place_id: int,
path: Path,
opencloud_key: str,
) -> None:
log.info("Cloning place %d into %s", place_id, path)
await _run(
atlas_binary,
"--opencloud",
opencloud_key,
"clone",
str(place_id),
"--path",
str(path),
"--skip-git",
"--skip-rules",
timeout=600,
)
async def atlas_syncback(
atlas_binary: str,
working_dir: Path,
place_id: int,
opencloud_key: str,
) -> str:
log.info("Syncing place %d in %s", place_id, working_dir)
stdout, _ = await _run(
atlas_binary,
"--opencloud",
opencloud_key,
"syncback",
"--download",
str(place_id),
"--working-dir",
str(working_dir),
"--incremental",
"--list",
timeout=600,
)
return stdout
async def git_init(cwd: Path) -> None:
await _run("git", "init", cwd=cwd)
async def git_add_all(cwd: Path) -> None:
await _run("git", "add", ".", cwd=cwd)
async def git_commit(cwd: Path, message: str) -> None:
await _run("git", "commit", "-m", message, "--allow-empty", cwd=cwd)
async def git_diff(cwd: Path) -> str:
try:
stdout, _ = await _run("git", "diff", "HEAD", cwd=cwd)
return stdout
except RuntimeError:
return ""
async def git_diff_name_only(cwd: Path) -> list[str]:
try:
stdout, _ = await _run("git", "diff", "HEAD", "--name-only", cwd=cwd)
return [line.strip() for line in stdout.splitlines() if line.strip()]
except RuntimeError:
return []

View file

@ -0,0 +1,307 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import anthropic
import discord
from discord import app_commands
from . import _bot_ref, daemon, security
if TYPE_CHECKING:
from .config import Config
from .db import Database
log = logging.getLogger(__name__)
class AtlasDaemonBot(discord.Client):
def __init__(
self,
config: Config,
db: Database,
anthropic_client: anthropic.AsyncAnthropic,
) -> None:
intents = discord.Intents.default()
intents.guilds = True
intents.guild_messages = True
super().__init__(intents=intents)
self.config = config
self.db = db
self.claude = anthropic_client
self.tree = app_commands.CommandTree(self)
_bot_ref.db = db
self._register_commands()
def _register_commands(self) -> None:
tree = self.tree
@tree.command(name="approve-server", description="Whitelist this server (or a remote one by ID) to use the bot")
@app_commands.describe(server_id="Optional: remote server ID to approve")
async def approve_server(interaction: discord.Interaction, server_id: str | None = None) -> None:
if not self.db.is_admin(str(interaction.user.id)):
await interaction.response.send_message("Only bot admins can approve servers.", ephemeral=True)
return
target_id = server_id or str(interaction.guild_id)
server_name = None
if not server_id and interaction.guild:
server_name = interaction.guild.name
self.db.approve_server(target_id, server_name, str(interaction.user.id))
await interaction.response.send_message(f"Server `{target_id}` approved.", ephemeral=True)
@tree.command(name="revoke-server", description="Remove a server from the whitelist")
@app_commands.describe(server_id="Server ID to revoke")
async def revoke_server(interaction: discord.Interaction, server_id: str) -> None:
if not self.db.is_admin(str(interaction.user.id)):
await interaction.response.send_message("Only bot admins can revoke servers.", ephemeral=True)
return
daemon.stop_all_for_server(self.db, server_id)
self.db.revoke_server(server_id)
await interaction.response.send_message(f"Server `{server_id}` revoked.", ephemeral=True)
@tree.command(name="set-api-key", description="Set your OpenCloud API key (requires legacy-asset:manage scope)")
@app_commands.describe(key="Your OpenCloud API key")
async def set_api_key(interaction: discord.Interaction, key: str) -> None:
if not self._check_server(interaction):
return
encrypted = security.encrypt_api_key(key)
for game in self.db.get_games_for_server(str(interaction.guild_id)):
if game.added_by == str(interaction.user.id):
self.db._conn.execute(
"UPDATE games SET api_key_encrypted = ? WHERE id = ?",
(encrypted, game.id),
)
self.db._conn.commit()
await interaction.response.send_message(
"API key updated for all your games in this server.", ephemeral=True
)
@tree.command(name="add-game", description="Start monitoring a Roblox game")
@app_commands.describe(
place_id="The Roblox Place ID to monitor",
channel="Channel to post reviews in (defaults to current)",
)
async def add_game(
interaction: discord.Interaction,
place_id: int,
channel: discord.TextChannel | None = None,
) -> None:
if not await self._check_server_async(interaction):
return
server_id = str(interaction.guild_id)
if self.db.count_games_for_server(server_id) >= self.config.max_games_per_server:
await interaction.response.send_message(
f"This server has reached the maximum of {self.config.max_games_per_server} monitored games.",
ephemeral=True,
)
return
if self.db.get_game(server_id, place_id):
await interaction.response.send_message(
f"PlaceId {place_id} is already being monitored in this server.",
ephemeral=True,
)
return
await interaction.response.defer(ephemeral=True)
target_channel = channel or interaction.channel
if not isinstance(target_channel, discord.TextChannel):
await interaction.followup.send("Please specify a text channel.", ephemeral=True)
return
games = self.db.get_games_for_server(server_id)
user_games = [g for g in games if g.added_by == str(interaction.user.id)]
api_key_encrypted: bytes | None = None
if user_games:
api_key_encrypted = user_games[0].api_key_encrypted
if api_key_encrypted is None:
await interaction.followup.send(
"No API key found. Please run `/set-api-key` first, then try again.",
ephemeral=True,
)
return
try:
opencloud_key = security.decrypt_api_key(api_key_encrypted)
game_dir = await daemon.init_game(
self.config, place_id, server_id, opencloud_key
)
except Exception as exc:
await interaction.followup.send(
f"Failed to initialize game: ```{str(exc)[:1500]}```", ephemeral=True
)
return
game_id = self.db.add_game(
server_id=server_id,
place_id=place_id,
channel_id=str(target_channel.id),
api_key_encrypted=api_key_encrypted,
added_by=str(interaction.user.id),
working_dir=str(game_dir),
)
game = self.db.get_game(server_id, place_id)
if game:
daemon.start_sync_loop(game, self.config, self.db, self, self.claude)
await interaction.followup.send(
f"Now monitoring PlaceId {place_id} in {target_channel.mention}. "
f"Syncing every {self.config.sync_interval}s.",
ephemeral=True,
)
@tree.command(name="remove-game", description="Stop monitoring a game")
@app_commands.describe(place_id="The Place ID to stop monitoring")
async def remove_game(interaction: discord.Interaction, place_id: int) -> None:
if not await self._check_server_async(interaction):
return
server_id = str(interaction.guild_id)
game = self.db.get_game(server_id, place_id)
if not game:
await interaction.response.send_message(
f"PlaceId {place_id} is not being monitored.", ephemeral=True
)
return
if game.added_by != str(interaction.user.id) and not self.db.is_admin(str(interaction.user.id)):
await interaction.response.send_message("You can only remove games you added.", ephemeral=True)
return
daemon.stop_sync_loop(game.id)
self.db.remove_game(server_id, place_id)
await interaction.response.send_message(f"Stopped monitoring PlaceId {place_id}.", ephemeral=True)
@tree.command(name="reset-game", description="Delete local clone and re-sync from scratch")
@app_commands.describe(place_id="The Place ID to reset")
async def reset_game(interaction: discord.Interaction, place_id: int) -> None:
if not await self._check_server_async(interaction):
return
server_id = str(interaction.guild_id)
game = self.db.get_game(server_id, place_id)
if not game:
await interaction.response.send_message(
f"PlaceId {place_id} is not being monitored.", ephemeral=True
)
return
if game.added_by != str(interaction.user.id) and not self.db.is_admin(str(interaction.user.id)):
await interaction.response.send_message("You can only reset games you added.", ephemeral=True)
return
await interaction.response.defer(ephemeral=True)
daemon.stop_sync_loop(game.id)
import shutil
from pathlib import Path
working_dir = Path(game.working_dir)
if working_dir.exists():
shutil.rmtree(working_dir, ignore_errors=True)
try:
opencloud_key = security.decrypt_api_key(game.api_key_encrypted)
await daemon.init_game(self.config, place_id, server_id, opencloud_key)
except Exception as exc:
await interaction.followup.send(
f"Failed to re-initialize: ```{str(exc)[:1500]}```", ephemeral=True
)
return
self.db._conn.execute(
"DELETE FROM issues WHERE game_id = ?", (game.id,)
)
self.db._conn.execute(
"UPDATE games SET last_sync_at = NULL, last_error = NULL, error_count = 0 WHERE id = ?",
(game.id,),
)
self.db._conn.commit()
game = self.db.get_game(server_id, place_id)
if game:
daemon.start_sync_loop(game, self.config, self.db, self, self.claude)
await interaction.followup.send(f"PlaceId {place_id} has been reset and re-synced.", ephemeral=True)
@tree.command(name="status", description="Show monitored games and sync status")
async def status(interaction: discord.Interaction) -> None:
if not await self._check_server_async(interaction):
return
games = self.db.get_games_for_server(str(interaction.guild_id))
if not games:
await interaction.response.send_message("No games are being monitored in this server.", ephemeral=True)
return
lines = []
for g in games:
status_icon = "🔴" if g.last_error else "🟢"
unresolved = len(self.db.get_unresolved_issues(g.id))
lines.append(
f"{status_icon} **PlaceId {g.place_id}** -- "
f"Last sync: {g.last_sync_at or 'Never'} -- "
f"Unresolved: {unresolved}"
)
if g.last_error:
error_preview = g.last_error[:100] + ("..." if len(g.last_error) > 100 else "")
lines.append(f" └ Error: {error_preview}")
await interaction.response.send_message("\n".join(lines), ephemeral=True)
@tree.command(name="list-games", description="List all monitored games in this server")
async def list_games(interaction: discord.Interaction) -> None:
if not await self._check_server_async(interaction):
return
games = self.db.get_games_for_server(str(interaction.guild_id))
if not games:
await interaction.response.send_message("No games monitored.", ephemeral=True)
return
lines = [
f"• PlaceId **{g.place_id}** → <#{g.channel_id}> (added by <@{g.added_by}>)"
for g in games
]
await interaction.response.send_message("\n".join(lines), ephemeral=True)
def _check_server(self, interaction: discord.Interaction) -> bool:
if not interaction.guild_id:
return False
return self.db.is_server_allowed(str(interaction.guild_id))
async def _check_server_async(self, interaction: discord.Interaction) -> bool:
if not interaction.guild_id:
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
return False
if not self.db.is_server_allowed(str(interaction.guild_id)):
await interaction.response.send_message(
"This server is not authorized. Ask a bot admin to run `/approve-server`.",
ephemeral=True,
)
return False
return True
async def on_ready(self) -> None:
log.info("Bot ready as %s", self.user)
await self.tree.sync()
log.info("Slash commands synced")
for game in self.db.get_all_games():
if self.db.is_server_allowed(game.discord_server_id):
daemon.start_sync_loop(game, self.config, self.db, self, self.claude)
log.info("Started sync loop for PlaceId %d", game.place_id)

View file

@ -0,0 +1,34 @@
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class Config:
discord_token: str
anthropic_key: str
data_dir: Path = field(default_factory=lambda: Path("./atlas-daemon-data"))
encryption_key: str = ""
sync_interval: int = 300
max_games_per_server: int = 5
atlas_binary: str = "atlas"
@classmethod
def from_env(cls, **overrides: object) -> Config:
def _get(key: str, default: str = "") -> str:
override = overrides.get(key.lower().removeprefix("atlas_daemon_"))
if override is not None:
return str(override)
return os.environ.get(f"ATLAS_DAEMON_{key}", default)
return cls(
discord_token=_get("DISCORD_TOKEN"),
anthropic_key=_get("ANTHROPIC_KEY"),
data_dir=Path(_get("DATA_DIR", "./atlas-daemon-data")),
encryption_key=_get("ENCRYPTION_KEY"),
sync_interval=int(_get("SYNC_INTERVAL", "300")),
max_games_per_server=int(_get("MAX_GAMES_PER_SERVER", "5")),
atlas_binary=_get("ATLAS_BINARY", "atlas"),
)

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))

View file

@ -0,0 +1,280 @@
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
GLOBAL_ADMIN_IDS = ["176643682689089545", "1010301288946339920"]
SCHEMA = """\
CREATE TABLE IF NOT EXISTS allowed_servers (
discord_server_id TEXT PRIMARY KEY,
server_name TEXT,
approved_by TEXT NOT NULL,
approved_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS admins (
discord_user_id TEXT PRIMARY KEY,
added_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
discord_server_id TEXT NOT NULL REFERENCES allowed_servers(discord_server_id) ON DELETE CASCADE,
place_id INTEGER NOT NULL,
channel_id TEXT NOT NULL,
api_key_encrypted BLOB NOT NULL,
added_by TEXT NOT NULL,
working_dir TEXT NOT NULL,
last_sync_at TEXT,
last_error TEXT,
last_error_at TEXT,
error_count INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(discord_server_id, place_id)
);
CREATE TABLE IF NOT EXISTS issues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,
discord_message_id TEXT,
file_path TEXT NOT NULL,
line_start INTEGER,
line_end INTEGER,
severity TEXT NOT NULL,
title TEXT NOT NULL,
explanation TEXT,
suggestion TEXT,
resolved INTEGER DEFAULT 0,
resolved_by TEXT,
resolved_reason TEXT,
resolved_at TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_issues_game_unresolved ON issues(game_id, resolved);
CREATE INDEX IF NOT EXISTS idx_games_server ON games(discord_server_id);
"""
@dataclass
class Game:
id: int
discord_server_id: str
place_id: int
channel_id: str
api_key_encrypted: bytes
added_by: str
working_dir: str
last_sync_at: str | None
last_error: str | None
last_error_at: str | None
error_count: int
created_at: str
@dataclass
class DBIssue:
id: int
game_id: int
discord_message_id: str | None
file_path: str
line_start: int | None
line_end: int | None
severity: str
title: str
explanation: str | None
suggestion: str | None
resolved: bool
resolved_by: str | None
resolved_reason: str | None
resolved_at: str | None
created_at: str
class Database:
def __init__(self, db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA foreign_keys=ON")
self._conn.executescript(SCHEMA)
self._seed_admins()
def _seed_admins(self) -> None:
for uid in GLOBAL_ADMIN_IDS:
self._conn.execute(
"INSERT OR IGNORE INTO admins (discord_user_id) VALUES (?)", (uid,)
)
self._conn.commit()
def is_admin(self, user_id: str) -> bool:
row = self._conn.execute(
"SELECT 1 FROM admins WHERE discord_user_id = ?", (user_id,)
).fetchone()
return row is not None
# -- Server whitelist --
def is_server_allowed(self, server_id: str) -> bool:
row = self._conn.execute(
"SELECT 1 FROM allowed_servers WHERE discord_server_id = ?", (server_id,)
).fetchone()
return row is not None
def approve_server(
self, server_id: str, server_name: str | None, approved_by: str
) -> None:
self._conn.execute(
"INSERT OR REPLACE INTO allowed_servers (discord_server_id, server_name, approved_by) VALUES (?, ?, ?)",
(server_id, server_name, approved_by),
)
self._conn.commit()
def revoke_server(self, server_id: str) -> None:
self._conn.execute(
"DELETE FROM allowed_servers WHERE discord_server_id = ?", (server_id,)
)
self._conn.commit()
# -- Games --
def add_game(
self,
server_id: str,
place_id: int,
channel_id: str,
api_key_encrypted: bytes,
added_by: str,
working_dir: str,
) -> int:
cur = self._conn.execute(
"INSERT INTO games (discord_server_id, place_id, channel_id, api_key_encrypted, added_by, working_dir) "
"VALUES (?, ?, ?, ?, ?, ?)",
(server_id, place_id, channel_id, api_key_encrypted, added_by, working_dir),
)
self._conn.commit()
return cur.lastrowid # type: ignore[return-value]
def remove_game(self, server_id: str, place_id: int) -> bool:
cur = self._conn.execute(
"DELETE FROM games WHERE discord_server_id = ? AND place_id = ?",
(server_id, place_id),
)
self._conn.commit()
return cur.rowcount > 0
def get_game(self, server_id: str, place_id: int) -> Game | None:
row = self._conn.execute(
"SELECT * FROM games WHERE discord_server_id = ? AND place_id = ?",
(server_id, place_id),
).fetchone()
return Game(*row) if row else None
def get_games_for_server(self, server_id: str) -> list[Game]:
rows = self._conn.execute(
"SELECT * FROM games WHERE discord_server_id = ?", (server_id,)
).fetchall()
return [Game(*r) for r in rows]
def get_all_games(self) -> list[Game]:
rows = self._conn.execute("SELECT * FROM games").fetchall()
return [Game(*r) for r in rows]
def count_games_for_server(self, server_id: str) -> int:
row = self._conn.execute(
"SELECT COUNT(*) FROM games WHERE discord_server_id = ?", (server_id,)
).fetchone()
return row[0] if row else 0
def update_last_sync(self, game_id: int) -> None:
now = datetime.now(timezone.utc).isoformat()
self._conn.execute(
"UPDATE games SET last_sync_at = ?, last_error = NULL, last_error_at = NULL, error_count = 0 WHERE id = ?",
(now, game_id),
)
self._conn.commit()
def update_last_error(self, game_id: int, error: str) -> bool:
"""Update error state. Returns True if this is a new error or throttle window expired."""
row = self._conn.execute(
"SELECT last_error, error_count FROM games WHERE id = ?", (game_id,)
).fetchone()
now = datetime.now(timezone.utc).isoformat()
if row and row[0] == error:
new_count = (row[1] or 0) + 1
self._conn.execute(
"UPDATE games SET error_count = ? WHERE id = ?",
(new_count, game_id),
)
self._conn.commit()
return new_count == 1 or new_count % 12 == 0
else:
self._conn.execute(
"UPDATE games SET last_error = ?, last_error_at = ?, error_count = 1 WHERE id = ?",
(error, now, game_id),
)
self._conn.commit()
return True
# -- Issues --
def get_unresolved_issues(self, game_id: int) -> list[DBIssue]:
rows = self._conn.execute(
"SELECT * FROM issues WHERE game_id = ? AND resolved = 0", (game_id,)
).fetchall()
return [DBIssue(*r) for r in rows]
def add_issue(
self,
game_id: int,
discord_message_id: str | None,
file_path: str,
line_start: int | None,
line_end: int | None,
severity: str,
title: str,
explanation: str | None,
suggestion: str | None,
) -> int:
cur = self._conn.execute(
"INSERT INTO issues (game_id, discord_message_id, file_path, line_start, line_end, "
"severity, title, explanation, suggestion) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(game_id, discord_message_id, file_path, line_start, line_end, severity, title, explanation, suggestion),
)
self._conn.commit()
return cur.lastrowid # type: ignore[return-value]
def resolve_issue(
self, issue_id: int, resolved_by: str, reason: str = "manual"
) -> None:
now = datetime.now(timezone.utc).isoformat()
self._conn.execute(
"UPDATE issues SET resolved = 1, resolved_by = ?, resolved_reason = ?, resolved_at = ? WHERE id = ?",
(resolved_by, reason, now, issue_id),
)
self._conn.commit()
def update_issue_message_id(self, issue_id: int, message_id: str) -> None:
self._conn.execute(
"UPDATE issues SET discord_message_id = ? WHERE id = ?",
(message_id, issue_id),
)
self._conn.commit()
def get_issue_by_message_id(self, message_id: str) -> DBIssue | None:
row = self._conn.execute(
"SELECT * FROM issues WHERE discord_message_id = ?", (message_id,)
).fetchone()
return DBIssue(*row) if row else None
def final_dedup(self, game_id: int, file_path: str, title: str) -> bool:
"""Returns True if this (file, title) pair already exists as unresolved."""
row = self._conn.execute(
"SELECT 1 FROM issues WHERE game_id = ? AND file_path = ? AND title = ? AND resolved = 0",
(game_id, file_path, title),
).fetchone()
return row is not None

View file

@ -0,0 +1,158 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import discord
if TYPE_CHECKING:
from .db import Game
from .review import Issue
SEVERITY_COLORS = {
"Low": 0x2ECC71,
"Medium": 0xF1C40F,
"High": 0xE67E22,
"Critical": 0xE74C3C,
}
def make_issue_embed(issue: Issue) -> discord.Embed:
color = SEVERITY_COLORS.get(issue.severity, 0x95A5A6)
title = f"[{issue.severity}] {issue.title}"
if len(title) > 256:
title = title[:253] + "..."
description_parts = [f"**File:** `{issue.file}`"]
if issue.line_start:
if issue.line_end and issue.line_end != issue.line_start:
description_parts.append(f"**Lines:** {issue.line_start}-{issue.line_end}")
else:
description_parts.append(f"**Line:** {issue.line_start}")
description_parts.append("")
description_parts.append(issue.explanation)
if issue.suggestion:
description_parts.append("")
suggestion_text = issue.suggestion
if len(suggestion_text) > 800:
suggestion_text = suggestion_text[:797] + "..."
description_parts.append(f"**Suggested fix:**\n```lua\n{suggestion_text}\n```")
description = "\n".join(description_parts)
if len(description) > 4096:
description = description[:4093] + "..."
return discord.Embed(title=title, description=description, color=color)
def make_summary_embed(
place_id: int,
new_issues: list[Issue],
auto_resolved_count: int,
changed_files: list[str],
lines_added: int,
lines_removed: int,
) -> discord.Embed:
severity_counts = {"Critical": 0, "High": 0, "Medium": 0, "Low": 0}
for issue in new_issues:
severity_counts[issue.severity] = severity_counts.get(issue.severity, 0) + 1
counts_str = " | ".join(
f"{count} {sev}" for sev, count in severity_counts.items()
)
description_parts = [
f"**{len(new_issues)} new issue{'s' if len(new_issues) != 1 else ''} found**",
]
if auto_resolved_count:
description_parts[0] += f" | {auto_resolved_count} auto-resolved"
description_parts.append(f"\n{counts_str}")
description_parts.append(
f"\nReviewed {len(changed_files)} changed script{'s' if len(changed_files) != 1 else ''} "
f"({lines_added} lines added, {lines_removed} removed)"
)
return discord.Embed(
title=f"Sync Review -- PlaceId {place_id}",
description="\n".join(description_parts),
color=0x3498DB,
)
def make_error_embed(place_id: int, error: str, error_type: str, last_sync: str | None) -> discord.Embed:
description = error
if len(description) > 1500:
description = description[:1497] + "..."
embed = discord.Embed(
title=f"Sync Error -- PlaceId {place_id}",
description=f"```\n{description}\n```",
color=0xE74C3C,
)
embed.add_field(name="Error Type", value=error_type, inline=True)
embed.add_field(name="Last Successful Sync", value=last_sync or "Never", inline=True)
embed.set_footer(text="Will retry next cycle")
return embed
class IssueButtons(discord.ui.View):
def __init__(self, issue_db_id: int) -> None:
super().__init__(timeout=None)
self.issue_db_id = issue_db_id
@discord.ui.button(label="Mark Fixed", style=discord.ButtonStyle.success, custom_id="mark_fixed")
async def mark_fixed(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
from . import _bot_ref
if _bot_ref.db is None:
await interaction.response.send_message("Database not available.", ephemeral=True)
return
_bot_ref.db.resolve_issue(self.issue_db_id, resolved_by=str(interaction.user.id))
embed = interaction.message.embeds[0] if interaction.message and interaction.message.embeds else None
if embed:
embed.set_footer(text=f"Resolved by {interaction.user.display_name}")
embed.color = 0x95A5A6
if interaction.message:
await interaction.message.edit(embed=embed, view=None)
await interaction.response.send_message("Marked as fixed.", ephemeral=True)
@discord.ui.button(label="Copy Context", style=discord.ButtonStyle.secondary, custom_id="copy_context")
async def copy_context(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
from . import _bot_ref
if _bot_ref.db is None:
await interaction.response.send_message("Database not available.", ephemeral=True)
return
issue = _bot_ref.db.get_issue_by_message_id(str(interaction.message.id)) if interaction.message else None
if not issue:
await interaction.response.send_message("Issue not found.", ephemeral=True)
return
context = (
f"File: {issue.file_path}\n"
f"Lines: {issue.line_start}-{issue.line_end}\n"
f"Severity: {issue.severity}\n"
f"Title: {issue.title}\n\n"
f"Explanation: {issue.explanation}\n\n"
f"Suggestion: {issue.suggestion}"
)
if len(context) > 1900:
context = context[:1897] + "..."
await interaction.response.send_message(f"```\n{context}\n```", ephemeral=True)
def count_diff_lines(diff: str) -> tuple[int, int]:
added = 0
removed = 0
for line in diff.splitlines():
if line.startswith("+") and not line.startswith("+++"):
added += 1
elif line.startswith("-") and not line.startswith("---"):
removed += 1
return added, removed

View file

@ -0,0 +1,185 @@
from __future__ import annotations
import asyncio
import json
import logging
import re
from dataclasses import dataclass
from importlib import resources
from typing import TYPE_CHECKING
import anthropic
if TYPE_CHECKING:
from .db import DBIssue
log = logging.getLogger(__name__)
REVIEWABLE_EXTENSIONS = (".luau", ".lua")
TOKEN_ESTIMATE_DIVISOR = 4
MAX_SINGLE_PASS_TOKENS = 150_000
@dataclass
class Issue:
file: str
line_start: int
line_end: int
severity: str
title: str
explanation: str
suggestion: str
def _load_system_prompt() -> str:
return resources.files("atlas_daemon").joinpath("review_prompt.txt").read_text()
def split_diff_by_file(raw_diff: str) -> list[str]:
chunks: list[str] = []
current: list[str] = []
for line in raw_diff.splitlines(keepends=True):
if line.startswith("diff --git "):
if current:
chunks.append("".join(current))
current = [line]
else:
current.append(line)
if current:
chunks.append("".join(current))
return chunks
def extract_file_path(file_diff: str) -> str:
for line in file_diff.splitlines():
if line.startswith("+++ b/"):
return line[6:].strip()
if line.startswith("+++ "):
return line[4:].strip()
return ""
def filter_script_changes(raw_diff: str) -> str:
filtered = []
for chunk in split_diff_by_file(raw_diff):
path = extract_file_path(chunk)
if any(path.endswith(ext) for ext in REVIEWABLE_EXTENSIONS):
filtered.append(chunk)
return "\n".join(filtered)
def format_existing_issues(existing: list[DBIssue]) -> str:
if not existing:
return ""
lines = ["Already-reported unresolved issues (do NOT re-report these):"]
for e in existing:
lines.append(f"- [{e.severity}] {e.file_path}:{e.line_start} -- {e.title}")
return "\n".join(lines)
def parse_issues(text: str) -> list[Issue]:
text = text.strip()
if text.startswith("```"):
text = re.sub(r"^```\w*\n?", "", text)
text = re.sub(r"\n?```$", "", text)
text = text.strip()
try:
data = json.loads(text)
except json.JSONDecodeError:
match = re.search(r"\[.*\]", text, re.DOTALL)
if match:
try:
data = json.loads(match.group())
except json.JSONDecodeError:
log.warning("Could not parse review response as JSON")
return []
else:
log.warning("No JSON array found in review response")
return []
if not isinstance(data, list):
return []
issues: list[Issue] = []
for item in data:
if not isinstance(item, dict):
continue
try:
issues.append(
Issue(
file=str(item.get("file", "")),
line_start=int(item.get("line_start", 0)),
line_end=int(item.get("line_end", 0)),
severity=str(item.get("severity", "Medium")),
title=str(item.get("title", "")),
explanation=str(item.get("explanation", "")),
suggestion=str(item.get("suggestion", "")),
)
)
except (ValueError, TypeError):
continue
return issues
async def review_diff(
client: anthropic.AsyncAnthropic,
diff: str,
existing_issues: list[DBIssue],
) -> list[Issue]:
script_diff = filter_script_changes(diff)
if not script_diff:
return []
existing_context = format_existing_issues(existing_issues)
token_estimate = len(script_diff) // TOKEN_ESTIMATE_DIVISOR
if token_estimate > MAX_SINGLE_PASS_TOKENS:
return await _review_chunked(client, script_diff, existing_context)
user_content = f"{existing_context}\n\nReview this diff:\n\n{script_diff}" if existing_context else f"Review this diff:\n\n{script_diff}"
try:
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
system=_load_system_prompt(),
messages=[{"role": "user", "content": user_content}],
)
return parse_issues(response.content[0].text)
except Exception:
log.exception("Claude API call failed")
raise
async def _review_chunked(
client: anthropic.AsyncAnthropic,
diff: str,
existing_context: str,
) -> list[Issue]:
file_diffs = split_diff_by_file(diff)
file_diffs = [
fd for fd in file_diffs
if any(extract_file_path(fd).endswith(ext) for ext in REVIEWABLE_EXTENSIONS)
]
async def review_one(file_diff: str) -> list[Issue]:
user_content = f"{existing_context}\n\nReview this diff:\n\n{file_diff}" if existing_context else f"Review this diff:\n\n{file_diff}"
try:
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
system=_load_system_prompt(),
messages=[{"role": "user", "content": user_content}],
)
return parse_issues(response.content[0].text)
except Exception:
log.exception("Claude API call failed for chunked review")
return []
results = await asyncio.gather(*[review_one(fd) for fd in file_diffs])
return [issue for file_issues in results for issue in file_issues]

View file

@ -0,0 +1,40 @@
You are a code reviewer specializing in Roblox Luau game development. You review unified diffs of Luau script files (.luau, .lua) and identify bugs, security issues, and anti-patterns.
RULES:
1. Analyze ONLY added/changed lines (lines starting with +). Do not flag removed code.
2. Focus on these categories: logic bugs, security vulnerabilities, performance problems, Roblox/Luau anti-patterns, race conditions from yielding.
3. Output ONLY a JSON array. No markdown, no commentary outside the JSON.
4. Each issue must have: file, line_start, line_end, severity, title, explanation, suggestion.
5. Severity levels:
- Critical: Will crash, corrupt data, or create security holes (infinite loop, data loss, remote code execution via loadstring/require tricks)
- High: Likely to cause incorrect behavior (off-by-one, wrong event connection, race condition after yield, accessing nil values)
- Medium: Could cause issues under certain conditions (missing nil check, unbounded table growth, memory leak from disconnected events)
- Low: Minor improvement (deprecated API, suboptimal pattern)
6. Luau-specific checks:
- Arrays are 1-indexed. Flag any for loop starting at 0: `for i = 0, #arr do`
- `wait()` is deprecated. Should be `task.wait()`
- `spawn()` is deprecated. Should be `task.spawn()`
- `delay()` is deprecated. Should be `task.delay()`
- `getfenv`/`setfenv` are deprecated and break optimizations
- `Instance.new("ClassName", parent)` - second arg is deprecated, use `.Parent = parent` after
- Yielding inside RenderStepped/Heartbeat/Stepped callbacks causes frame drops
- Missing `task.cancel()` on spawned threads during cleanup
- Unbounded `table.insert` without cleanup (memory leak)
- Using `pairs()` or `ipairs()` when direct `for k, v in tbl do` works
- `string.format` with user input without sanitization
7. Do NOT flag: style preferences, formatting, naming conventions, missing comments, TODO items, whitespace changes.
8. If no issues are found, return an empty array: []
9. Skip any issues listed in the "Already-reported unresolved issues" section - these are known and tracked.
OUTPUT FORMAT (strict JSON, no markdown fences):
[
{
"file": "src/server/PlayerManager.server.luau",
"line_start": 47,
"line_end": 52,
"severity": "Critical",
"title": "0-indexed loop over 1-indexed array",
"explanation": "Luau arrays are 1-indexed. This loop starts at 0, which returns nil on the first iteration and skips the last element entirely.",
"suggestion": "for i = 1, #players do"
}
]

View file

@ -0,0 +1,32 @@
from __future__ import annotations
from cryptography.fernet import Fernet
_fernet: Fernet | None = None
def init_encryption(key: str) -> None:
global _fernet
if not key:
_fernet = Fernet(Fernet.generate_key())
return
if len(key) == 44 and key.endswith("="):
_fernet = Fernet(key.encode())
else:
import base64
import hashlib
derived = hashlib.sha256(key.encode()).digest()
_fernet = Fernet(base64.urlsafe_b64encode(derived))
def encrypt_api_key(plaintext: str) -> bytes:
if _fernet is None:
raise RuntimeError("Encryption not initialized. Call init_encryption() first.")
return _fernet.encrypt(plaintext.encode())
def decrypt_api_key(ciphertext: bytes) -> str:
if _fernet is None:
raise RuntimeError("Encryption not initialized. Call init_encryption() first.")
return _fernet.decrypt(ciphertext).decode()