diff --git a/.cursor/plans/atlas_daemon_python_bot_be6b0651.plan.md b/.cursor/plans/atlas_daemon_python_bot_be6b0651.plan.md new file mode 100644 index 00000000..90252ba0 --- /dev/null +++ b/.cursor/plans/atlas_daemon_python_bot_be6b0651.plan.md @@ -0,0 +1,646 @@ +--- +name: Atlas Daemon Python Bot +overview: Add --opencloud flag to atlas CLI for API-key-based Roblox access, then build a Python Discord bot daemon that uses atlas commands to periodically syncback games, review diffs with Anthropic Claude, and post Cursor-style issue reports. +todos: + - id: rust-opencloud + content: Add --opencloud global flag to atlas CLI, create src/roblox_api.rs shared auth module, update download_place() in syncback.rs to support API key via OpenCloud Asset Delivery API. + status: completed + - id: rust-clone-opencloud + content: Update clone.rs to pass --opencloud through to syncback and fetch_experience_name(). + status: completed + - id: rust-studio-upload-opencloud + content: Update studio.rs get_universe_id() and upload.rs to accept --opencloud as fallback for API key auth. + status: completed + - id: scaffold + content: Scaffold tools/atlas-daemon/ with pyproject.toml, package structure, and __main__.py CLI entry point. + status: completed + - id: config-db + content: Implement config.py (env loading) and db.py (SQLite schema with allowed_servers table, migrations, CRUD for games/issues/admins/servers). + status: completed + - id: security + content: "Implement security.py: Fernet encryption for API keys, admin permission checks, server whitelist enforcement." + status: completed + - id: atlas-cli + content: "Implement atlas_cli.py: async subprocess wrappers for atlas clone --opencloud, atlas syncback --opencloud --download, git diff, git commit." + status: completed + - id: review-engine + content: "Implement review.py + review_prompt.txt: Anthropic Claude API call with Luau-focused code review prompt, JSON output parsing, diff chunking for large diffs." + status: completed + - id: discord-fmt + content: "Implement discord_fmt.py: format issues as Discord embeds with severity colors, code blocks, buttons, error reporting embeds." + status: completed + - id: bot-commands + content: "Implement bot.py + commands/: discord.py bot with all slash commands, button interaction handlers, and /approve-server admin command." + status: completed + - id: daemon-loop + content: "Implement daemon.py: per-game asyncio task loop (syncback --download --opencloud -> git diff -> review -> post), with first-sync skip, issue dedup, and error reporting." + status: completed + - id: main-entry + content: "Wire everything together in __main__.py: parse args, init DB, start bot + daemon loops concurrently." + status: completed +isProject: false +--- + +# Atlas Daemon - Automated Roblox Code Review Bot + +Two-phase implementation: (1) add `--opencloud` support to the atlas Rust binary, then (2) build the Python Discord bot daemon that uses those commands. + +--- + +## Phase 1: Rust -- Add `--opencloud` to Atlas CLI + +### Inventory of Roblox API Calls + + +| File | Function | URL | Current Auth | Needs `--opencloud` | +| ---------------------------------- | ------------------------- | ---------------------------------------------------- | --------------------- | ---------------------------------------------------------- | +| [syncback.rs](src/cli/syncback.rs) | `download_place()` | `assetdelivery.roblox.com/v1/asset/?id=` | Cookie (required) | YES -- use Asset Delivery API | +| [clone.rs](src/cli/clone.rs) | `fetch_experience_name()` | `apis.roblox.com/universes/v1/places/{}/universe` | Cookie (optional) | YES -- same endpoint, add API key header | +| [clone.rs](src/cli/clone.rs) | `fetch_experience_name()` | `games.roblox.com/v1/games?universeIds=` | Cookie (optional) | BEST-EFFORT -- may not support API key, graceful fallback | +| [upload.rs](src/cli/upload.rs) | `do_upload_legacy()` | `data.roblox.com/Data/Upload.ashx` | Cookie | NO -- legacy path, keep as-is | +| [upload.rs](src/cli/upload.rs) | `do_upload_asset()` | `apis.roblox.com/assets/v1/assets/{}` | API key (`--api_key`) | ALIGN -- fall back to `--opencloud` if `--api_key` not set | +| [upload.rs](src/cli/upload.rs) | `do_upload_place()` | `apis.roblox.com/universes/v1/{}/places/{}/versions` | API key (`--api_key`) | ALIGN -- same fallback | +| [studio.rs](src/cli/studio.rs) | `get_universe_id()` | `apis.roblox.com/universes/v1/places/{}/universe` | Cookie (optional) | YES -- add API key header | + + +### 1. Global `--opencloud` Flag + +Add to `GlobalOptions` in [src/cli/mod.rs](src/cli/mod.rs): + +```rust +#[clap(long, env = "ATLAS_OPENCLOUD_KEY", global(true), hide = true)] +pub opencloud: Option, +``` + +Hidden from help (power-user / daemon feature), also readable via `ATLAS_OPENCLOUD_KEY` env var. + +### 2. Shared Auth Module: `src/roblox_api.rs` + +New module consolidating all Roblox HTTP auth logic: + +```rust +pub enum RobloxAuth { + Cookie(String), + ApiKey(String), +} + +/// Resolve auth: prefer --opencloud API key, fall back to system cookie. +pub fn resolve_auth(opencloud_key: Option<&str>) -> anyhow::Result; + +/// Download a place file. Uses OpenCloud Asset Delivery API with API key, +/// or legacy assetdelivery.roblox.com with cookie. +pub fn download_place(place_id: u64, auth: &RobloxAuth) -> anyhow::Result; + +/// Resolve place_id -> universe_id. Works with both auth methods. +pub fn get_universe_id(place_id: u64, auth: &RobloxAuth) -> anyhow::Result>; + +/// Resolve place_id -> experience name. Best-effort with API key +/// (games.roblox.com may not support it), always works with cookie. +pub fn fetch_experience_name(place_id: u64, auth: &RobloxAuth) -> anyhow::Result>; +``` + +**OpenCloud download path** (when `RobloxAuth::ApiKey`): + +``` +GET https://apis.roblox.com/asset-delivery-api/v1/assetId/{place_id} +Header: x-api-key: +Scope required: legacy-asset:manage +Response: JSON { "location": "" } +-> GET -> raw rbxl bytes +``` + +**Cookie download path** (when `RobloxAuth::Cookie`): existing logic from `syncback.rs:download_place()`, moved into this module. + +### 3. Per-Command Changes + +**[syncback.rs](src/cli/syncback.rs)**: Replace inline `download_place()` with call to `roblox_api::download_place()`. Pass `global.opencloud` through. + +**[clone.rs](src/cli/clone.rs)**: Replace inline `fetch_experience_name()` with `roblox_api::fetch_experience_name()`. Pass `global.opencloud` through. Clone constructs a `SyncbackCommand` internally -- the global `--opencloud` flag propagates through to syncback's `download_place()` automatically. Result: `atlas --opencloud KEY clone PLACEID --path DIR` works end-to-end with no cookie needed. + +**[studio.rs](src/cli/studio.rs)**: Replace inline `get_universe_id()` with `roblox_api::get_universe_id()`. Pass `global.opencloud` through. + +**[upload.rs](src/cli/upload.rs)**: If `--api_key` is not set but `--opencloud` is, use `--opencloud` as the API key for Open Cloud upload paths. Legacy upload still requires cookie. + +--- + +## Phase 2: Python Discord Bot Daemon + +### Architecture + +Python project at `tools/atlas-daemon/` that orchestrates the `atlas` binary via subprocess. Now that atlas supports `--opencloud`, the daemon simply passes the user's API key as a CLI flag -- no need for Python to download rbxl files directly. + +```mermaid +flowchart TB + subgraph daemon ["atlas-daemon (Python)"] + Bot["discord.py Bot"] + Scheduler["asyncio Task Scheduler"] + AtlasCLI["atlas CLI (subprocess)"] + GitDiff["git diff (subprocess)"] + Reviewer["Claude Review (anthropic SDK)"] + DB["SQLite (stdlib)"] + end + + Scheduler -->|"every ~5 min per game"| AtlasCLI + AtlasCLI -->|"atlas syncback --opencloud KEY --download PLACEID"| GitDiff + GitDiff -->|"unified diff"| Reviewer + Reviewer -->|"issues JSON"| Bot + Bot -->|"embeds + code blocks"| Discord + Bot -->|"slash commands"| DB + Discord["Discord Channels"] +``` + + + +### Project Structure + +``` +tools/atlas-daemon/ + pyproject.toml + atlas_daemon/ + __init__.py + __main__.py -- CLI entry point (click) + bot.py -- discord.py bot, event handler, slash command tree + commands/ + __init__.py + approve_server.py -- /approve-server [server_id] (global admin only) + revoke_server.py -- /revoke-server (global admin only) + add_game.py -- /add-game [#channel] + remove_game.py -- /remove-game + reset_game.py -- /reset-game + set_api_key.py -- /set-api-key (ephemeral) + status.py -- /status + list_games.py -- /list-games + daemon.py -- Per-game sync loop (asyncio.create_task per game) + review.py -- Anthropic Claude code review + prompt + db.py -- SQLite schema, migrations, CRUD + discord_fmt.py -- Format issues + errors as Discord embeds + security.py -- API key encryption (Fernet), permission checks, server whitelist + config.py -- Env var loading, dataclass config + atlas_cli.py -- Subprocess wrappers for atlas commands + review_prompt.txt -- The code review system prompt +``` + +Note: `download.py` is no longer needed -- atlas handles download natively with `--opencloud`. + +### Dependencies + +```toml +[project] +name = "atlas-daemon" +requires-python = ">=3.11" +dependencies = [ + "discord.py>=2.4", + "anthropic>=0.40", + "cryptography>=43", + "click>=8.1", +] +``` + +`httpx` removed -- no longer downloading in Python. + +### Core Flows + +#### 1. Atlas CLI Wrappers (`atlas_cli.py`) + +```python +async def atlas_clone(place_id: int, path: Path, opencloud_key: str) -> None: + """Initialize a new game project via atlas clone.""" + cmd = [ + config.atlas_binary, "--opencloud", opencloud_key, + "clone", str(place_id), + "--path", str(path), + "--skip-git", + ] + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + raise RuntimeError(f"atlas clone failed: {stderr.decode()}") + +async def atlas_syncback(working_dir: Path, place_id: int, opencloud_key: str) -> str: + """Download + syncback in one command.""" + cmd = [ + config.atlas_binary, "--opencloud", opencloud_key, + "syncback", + "--download", str(place_id), + "--working-dir", str(working_dir), + "--incremental", + "--list", + ] + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + raise RuntimeError(f"atlas syncback failed: {stderr.decode()}") + return stdout.decode() +``` + +#### 2. Initial Game Setup (on `/add-game`) + +Uses `atlas clone --opencloud` directly -- it handles everything: fetching the game name, creating the project, downloading the rbxl, and running syncback. + +```python +async def init_game(place_id: int, server_id: str, opencloud_key: str) -> Path: + game_dir = config.data_dir / server_id / str(place_id) + + # atlas clone handles: fetch name, create project, download rbxl, syncback + await run( + config.atlas_binary, "--opencloud", opencloud_key, + "clone", str(place_id), + "--path", str(game_dir), + "--skip-git", # we manage git ourselves + ) + + # Init git and commit baseline (no review on first sync) + await run("git", "init", cwd=game_dir) + await run("git", "add", ".", cwd=game_dir) + await run("git", "commit", "-m", "Initial sync", cwd=game_dir) + + return game_dir +``` + +First sync is committed without review (everything is new, reviewing the entire codebase would be noise). + +#### 3. Periodic Sync Loop (`daemon.py`) + +```python +async def sync_loop(game: Game, bot: discord.Client): + while True: + try: + api_key = decrypt_api_key(game.api_key_encrypted) + + # Download + syncback via atlas CLI + output = await atlas_syncback(game.working_dir, game.place_id, api_key) + + # Check for changes + diff = await git_diff(game.working_dir) + if diff: + # Filter to script changes only, chunk if too large + script_diff = filter_script_changes(diff) + if script_diff: + issues = await review_diff(script_diff, game) + new_issues = dedup_issues(issues, game.id) # skip already-reported + if new_issues: + await post_issues(bot, game, new_issues) + + await git_commit(game.working_dir, f"Sync {datetime.utcnow().isoformat()}") + update_last_sync(game.id) + + except Exception as e: + await report_error_to_discord(bot, game, e) + update_last_error(game.id, str(e)) + + await asyncio.sleep(config.sync_interval) +``` + +#### 4. AI Code Review (`review.py`) + +Modeled after Cursor's BugBot architecture (from their [blog](https://cursor.com/blog/building-bugbot)): + +- BugBot runs **multiple parallel passes** with randomized diff order and uses **majority voting** to surface high-confidence issues +- It feeds prior review comments as context to avoid duplicates +- It uses a validator to filter false positives + +We implement a simplified version of this pipeline. + +**Step 1: Filter the diff to reviewable content** + +Strip non-script changes before sending to Claude. The daemon only reviews Luau code -- binary models, meta files, and project files are noise. + +```python +REVIEWABLE_EXTENSIONS = {".luau", ".lua"} + +def filter_script_changes(raw_diff: str) -> str: + """Parse unified diff, keep only hunks for script files.""" + filtered_hunks = [] + for file_diff in split_diff_by_file(raw_diff): + path = extract_file_path(file_diff) + if any(path.endswith(ext) for ext in REVIEWABLE_EXTENSIONS): + filtered_hunks.append(file_diff) + return "\n".join(filtered_hunks) +``` + +**Step 2: Send to Claude (full diff, no chunking unless truly massive)** + +Claude Sonnet has a 200k token context window. A typical 5-minute diff for a game is well under this. Only chunk if the filtered diff exceeds ~150k tokens (rare -- would require hundreds of scripts changing simultaneously). + +```python +client = anthropic.AsyncAnthropic(api_key=config.anthropic_key) + +async def review_diff(diff: str, game: Game) -> list[Issue]: + script_diff = filter_script_changes(diff) + if not script_diff: + return [] + + # Build dedup context (see section 5) + existing = db.get_unresolved_issues(game.id) + existing_context = format_existing_issues(existing) + + token_estimate = len(script_diff) // 4 + if token_estimate > 150_000: + return await _review_chunked(script_diff, existing_context) + + response = await client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=4096, + system=load_system_prompt(), + messages=[{"role": "user", "content": f"{existing_context}\n\nReview this diff:\n\n{script_diff}"}], + ) + issues = parse_issues(response.content[0].text) + return final_dedup(issues, game.id) +``` + +**Step 3: For oversized diffs, chunk by file and review in parallel** + +```python +async def _review_chunked(diff: str, existing_context: str) -> list[Issue]: + file_diffs = split_diff_by_file(diff) + tasks = [ + _review_single_file(fd, existing_context) + for fd in file_diffs + ] + results = await asyncio.gather(*tasks) + return [issue for file_issues in results for issue in file_issues] +``` + +**The system prompt** (`review_prompt.txt`) instructs Claude to: + +1. Analyze the unified diff line-by-line, focusing ONLY on added/changed lines (lines starting with `+`) +2. Flag issues in these categories: logic bugs, security vulnerabilities, performance problems, Roblox/Luau anti-patterns, race conditions from yielding +3. Output a JSON array: `[{"file": "path", "line_start": N, "line_end": N, "severity": "Low|Medium|High|Critical", "title": "short title", "explanation": "why this is a problem and what could go wrong", "suggestion": "corrected code snippet"}]` +4. Severity guide: + - **Critical**: Will crash, corrupt data, or create security holes (infinite loop, data loss, remote code execution) + - **High**: Likely to cause incorrect behavior (off-by-one, wrong event connection, race condition) + - **Medium**: Could cause issues under certain conditions (missing nil check, unbounded table growth) + - **Low**: Minor improvement opportunity (deprecated API usage, suboptimal pattern) +5. Luau-specific checks: 1-indexed arrays, `wait()` instead of `task.wait()`, `getfenv`/`setfenv`, 0-based loops, missing `task.cancel()`, unbounded `table.insert` without cleanup, yielding in `RenderStepped`/`Heartbeat`, `Instance.new` with parent as second arg (deprecated) +6. Do NOT flag: style preferences, formatting, naming conventions, missing comments, TODO items +7. If no issues found, return an empty array `[]` +8. Skip any issues already listed in the "existing unresolved issues" context + +#### 5. Issue Deduplication + +Claude will flag the same bug every sync cycle until it's fixed. We solve this at two layers. + +**Layer 1: Prompt context (primary)** + +Feed Claude the list of unresolved issues so it skips them. This mirrors how Cursor's BugBot "reads GitHub PR comments (top-level and inline) to avoid duplicate suggestions and build on prior feedback." + +```python +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) +``` + +This context is prepended to the user message in every review call (see section 4). + +**Layer 2: Hard dedup in Python (safety net)** + +If Claude re-reports despite the context, catch exact matches before posting: + +```python +def final_dedup(new_issues: list[Issue], game_id: int) -> list[Issue]: + existing = db.get_unresolved_issues(game_id) + existing_keys = {(e.file_path, e.title) for e in existing} + return [i for i in new_issues if (i.file, i.title) not in existing_keys] +``` + +**Layer 3: Auto-resolution** + +When a file with unresolved issues is modified in a subsequent diff, auto-resolve those issues: + +```python +def auto_resolve_changed_files(diff: str, game_id: int) -> list[int]: + """Mark issues as auto-resolved if their file was modified.""" + changed_files = extract_changed_file_paths(diff) + 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 +``` + +Auto-resolved issues get their Discord embed updated with a "Resolved -- code was modified" footer. This runs *before* the Claude review, so the auto-resolved issues don't appear in the dedup context (they might have been fixed, so Claude should re-check the new version). + +#### 6. Discord Output Format + +Each issue posted as a rich embed with buttons: + +``` +[Critical] Potential infinite loop in PlayerManager.server.luau +Line 47-52 + + for i = 0, #players do -- BUG: 0-indexed loop + local player = players[i] + if player then + processPlayer(player) + end + end + +Explanation: Luau arrays are 1-indexed. This loop starts at 0, +missing the last element. Use `for i = 1, #players do`. + +Suggested fix: + for i = 1, #players do +``` + +- Embeds color-coded: Low=0x2ecc71 (green), Medium=0xf1c40f (yellow), High=0xe67e22 (orange), Critical=0xe74c3c (red) +- Critical issues trigger `@here` mention +- Two buttons per issue: + - "Mark Fixed" -- sets resolved=True in SQLite, updates embed to show who resolved it and when + - "Copy Context" -- sends ephemeral message with full issue in a copyable code block (file path, line numbers, code, explanation, suggestion -- formatted for pasting into Cursor or a ticket) +- Issues grouped per sync cycle: one summary embed header, then individual issue embeds +- Auto-resolved issues get their embed edited with a strikethrough title and "Resolved -- code was modified" footer + +**Summary embed** (posted once per sync cycle with issues): + +``` +Sync Review -- PlaceId 12345678 +3 new issues found | 2 auto-resolved + + 1 Critical | 1 High | 1 Medium | 0 Low + +Reviewed 4 changed scripts (127 lines added, 23 removed) +``` + +**Error reporting** (`discord_fmt.py`): + +Every error type gets its own embed posted to the game's channel. Errors are not silent -- operators need to know immediately. + + +| Error Type | Embed Color | Content | +| ------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- | +| Download failure (OpenCloud 403/401) | Red | "API key may be invalid or missing `legacy-asset:manage` scope. Use `/set-api-key` to update." | +| Download failure (network) | Red | "Could not reach Roblox API. Will retry next cycle." | +| Syncback crash (atlas exit code != 0) | Red | Full stderr output in a code block, truncated to 1500 chars | +| Claude API error (rate limit, auth) | Orange | "Review skipped this cycle. Changes were still committed." | +| Claude parse error (invalid JSON) | Orange | "Claude returned unparseable response. Raw output logged." | +| Git error | Red | Full error message | + + +```python +async def report_error(bot: discord.Client, game: Game, error: Exception, error_type: str): + channel = bot.get_channel(int(game.channel_id)) + embed = discord.Embed( + title=f"Sync Error -- PlaceId {game.place_id}", + description=truncate(str(error), 1500), + color=0xe74c3c, + ) + embed.add_field(name="Error Type", value=error_type, inline=True) + embed.add_field(name="Last Successful Sync", value=game.last_sync_at or "Never", inline=True) + embed.set_footer(text="Will retry next cycle") + await channel.send(embed=embed) +``` + +**Error throttling**: Don't spam the channel with the same error every 5 minutes. Track the last error message in the `games` table. If the current error is identical to `last_error`, only re-post after 1 hour of continuous failure (12 consecutive cycles). On the first occurrence of a *new* error, always post immediately. + +#### 7. Discord Slash Commands + + +| Command | Args | Permission | Description | +| ----------------- | -------------------------------- | -------------------- | ----------------------------------------------------- | +| `/approve-server` | `server_id` (optional) | Global admin only | Whitelist current server (or remote by ID) | +| `/revoke-server` | `server_id` | Global admin only | Remove server from whitelist, stop all its sync loops | +| `/add-game` | `place_id`, `channel` (optional) | Admin or has API key | Start monitoring a game in this server | +| `/remove-game` | `place_id` | Admin or game owner | Stop monitoring, optionally delete local data | +| `/reset-game` | `place_id` | Admin or game owner | Delete clone, re-sync from scratch | +| `/set-api-key` | `key` | Any permitted user | Set OpenCloud key (ephemeral, encrypted) | +| `/status` | -- | Any permitted user | Show monitored games, last sync, error counts | +| `/list-games` | -- | Any permitted user | List games in this server | + + +#### 8. Security Model + +**Server whitelist (allowed_servers)**: + +Every slash command and every bot action checks the server whitelist first. If the server is not in `allowed_servers`, the bot responds with an ephemeral "This server is not authorized. Ask an admin to run `/approve-server`." and does nothing else. This is the first check in every interaction handler -- no exceptions. + +```python +async def check_server_allowed(interaction: discord.Interaction) -> bool: + if not db.is_server_allowed(str(interaction.guild_id)): + await interaction.response.send_message( + "This server is not authorized. Ask a bot admin to approve it.", + ephemeral=True, + ) + return False + return True +``` + +The `allowed_servers` table is managed exclusively by global admins: + + +| Command | Who can run it | What it does | +| ------------------------------------------------------------ | ----------------- | ------------------------------------------------------------ | +| `/approve-server` (no args, run in target server) | Global admin only | Adds current server to whitelist | +| `/approve-server ` (run from any approved server) | Global admin only | Adds a remote server by ID | +| `/revoke-server ` | Global admin only | Removes server, stops all its sync loops, keeps data on disk | + + +On bot startup, the daemon loads all approved servers and only starts sync loops for games in those servers. + +**Full permission model:** + +- **Global admins** (seeded in DB on first run): Discord user IDs `176643682689089545`, `1010301288946339920` +- **Per-user API keys**: Provided via `/set-api-key` (ephemeral response, never logged, never visible to other users). Encrypted with Fernet (symmetric, derived from `ATLAS_DAEMON_ENCRYPTION_KEY` env var) before SQLite storage. +- **Server isolation**: Games scoped to `/`. Multiple servers can independently monitor the same place with separate working directories and separate API keys. +- **Rate limiting**: Configurable max games per server (default 5, via `ATLAS_DAEMON_MAX_GAMES_PER_SERVER`). +- **Command permissions**: Non-admin users can only manage games they added (`added_by` field). Admins can manage any game in any approved server. + +#### 9. SQLite Schema + +```sql +-- Server whitelist. No entry = bot refuses all commands in that server. +CREATE TABLE allowed_servers ( + discord_server_id TEXT PRIMARY KEY, + server_name TEXT, -- cached for /status display + approved_by TEXT NOT NULL, -- discord user ID of admin who approved + approved_at TEXT DEFAULT (datetime('now')) +); + +-- Global admin users. Seeded on first run with hardcoded IDs. +CREATE TABLE admins ( + discord_user_id TEXT PRIMARY KEY, + added_at TEXT DEFAULT (datetime('now')) +); + +-- Monitored games. FK to allowed_servers ensures orphan cleanup on revoke. +CREATE TABLE 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, -- Fernet-encrypted OpenCloud key + added_by TEXT NOT NULL, -- discord user ID + working_dir TEXT NOT NULL, -- absolute path to game's project directory + last_sync_at TEXT, + last_error TEXT, -- last error message (for throttling) + last_error_at TEXT, -- when last_error first occurred + error_count INTEGER DEFAULT 0, -- consecutive error count (for throttling) + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(discord_server_id, place_id) +); + +-- Reported issues. Tracks every issue for dedup + mark-as-fixed. +CREATE TABLE issues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE, + discord_message_id TEXT, -- for editing the embed on resolve + file_path TEXT NOT NULL, + line_start INTEGER, + line_end INTEGER, + severity TEXT NOT NULL, -- Low, Medium, High, Critical + title TEXT NOT NULL, + explanation TEXT, + suggestion TEXT, + resolved INTEGER DEFAULT 0, + resolved_by TEXT, -- discord user ID or "auto" + resolved_reason TEXT, -- "manual", "file modified", etc. + resolved_at TEXT, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE INDEX idx_issues_game_unresolved ON issues(game_id, resolved) WHERE resolved = 0; +CREATE INDEX idx_games_server ON games(discord_server_id); +``` + +#### 10. Configuration + +```env +ATLAS_DAEMON_DISCORD_TOKEN= +ATLAS_DAEMON_ANTHROPIC_KEY= +ATLAS_DAEMON_DATA_DIR=./atlas-daemon-data +ATLAS_DAEMON_ENCRYPTION_KEY= +ATLAS_DAEMON_SYNC_INTERVAL=300 +ATLAS_DAEMON_MAX_GAMES_PER_SERVER=5 +ATLAS_DAEMON_ATLAS_BINARY=atlas +``` + +#### 11. CLI Interface + +```bash +python -m atlas_daemon \ + --discord-token $TOKEN \ + --anthropic-key $KEY \ + --data-dir ./data \ + --sync-interval 300 +``` + +## Audit Gap Resolution + + +| Gap | Fix | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GAP 1: `atlas clone` uses cookie | FIXED: `--opencloud` global flag on atlas CLI. `atlas --opencloud KEY clone PLACEID` works end-to-end with no cookie. Daemon calls `atlas clone` for init, `atlas syncback --download` for periodic syncs. | +| GAP 2: No server whitelist | FIXED: `allowed_servers` table with FK cascade to `games`. `/approve-server` and `/revoke-server` admin commands. Every interaction handler checks whitelist first -- no exceptions. | +| GAP 3: Error reporting not specified | FIXED: Typed error embeds per failure mode (download, syncback, Claude, git) posted to game channel with color coding. Error throttling: same error only re-posted after 1 hour (12 consecutive cycles). Error state tracked via `last_error`, `last_error_at`, `error_count` in `games` table. | +| GAP 4: No diff size handling | FIXED: `filter_script_changes()` strips non-Luau files before review. Full filtered diff sent to Claude (200k context handles normal syncs). Only chunk by file + parallel review for truly massive diffs (>150k estimated tokens). First sync committed without review. | +| GAP 5: No issue deduplication | FIXED: Three-layer approach. (1) Prompt context: unresolved issues fed to Claude so it avoids re-reporting. (2) Hard dedup: exact `(file_path, title)` match in Python as safety net. (3) Auto-resolution: issues in files modified by a subsequent diff auto-resolved before review, Discord embeds updated with "Resolved -- code was modified" footer. | + + diff --git a/Cargo.toml b/Cargo.toml index dd7af375..abbd4ab9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,7 +104,7 @@ tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "time"] } uuid = { version = "1.21.0", features = ["v4", "serde"] } -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" profiling = "1.0.17" yaml-rust2 = "0.11.0" diff --git a/src/cli/clone.rs b/src/cli/clone.rs index b5ee1fdd..e815edb1 100644 --- a/src/cli/clone.rs +++ b/src/cli/clone.rs @@ -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 ." + ), + }; 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, -} - -#[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 { - 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 diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 950ab829..8674f55f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -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, } #[derive(Debug, Clone, Copy)] diff --git a/src/cli/studio.rs b/src/cli/studio.rs index a0779604..5b5f644e 100644 --- a/src/cli/studio.rs +++ b/src/cli/studio.rs @@ -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 { - 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) -} diff --git a/src/cli/syncback.rs b/src/cli/syncback.rs index f8b1a3fd..a122d3d1 100644 --- a/src/cli/syncback.rs +++ b/src/cli/syncback.rs @@ -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 { .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 { - 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 { let content = BufReader::new(File::open(path)?); match file_kind { diff --git a/src/cli/upload.rs b/src/cli/upload.rs index 5c7f26e3..98b91157 100644 --- a/src/cli/upload.rs +++ b/src/cli/upload.rs @@ -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() { diff --git a/src/lib.rs b/src/lib.rs index b4f7e6ad..2fe003c1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/roblox_api.rs b/src/roblox_api.rs new file mode 100644 index 00000000..815c7838 --- /dev/null +++ b/src/roblox_api.rs @@ -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 { + 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 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 { + 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 { + 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> { + 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> { + 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, +} + +#[derive(Debug, Deserialize)] +struct UniverseResponse { + #[serde(rename = "universeId")] + universe_id: u64, +} + +#[derive(Debug, Deserialize)] +struct GamesResponse { + data: Vec, +} + +#[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 { + 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> { + 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 = 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::() { + 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)), + } +} diff --git a/tools/atlas-daemon/atlas_daemon/__init__.py b/tools/atlas-daemon/atlas_daemon/__init__.py new file mode 100644 index 00000000..08f52b9e --- /dev/null +++ b/tools/atlas-daemon/atlas_daemon/__init__.py @@ -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() diff --git a/tools/atlas-daemon/atlas_daemon/__main__.py b/tools/atlas-daemon/atlas_daemon/__main__.py new file mode 100644 index 00000000..1f611630 --- /dev/null +++ b/tools/atlas-daemon/atlas_daemon/__main__.py @@ -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() diff --git a/tools/atlas-daemon/atlas_daemon/atlas_cli.py b/tools/atlas-daemon/atlas_daemon/atlas_cli.py new file mode 100644 index 00000000..271267f2 --- /dev/null +++ b/tools/atlas-daemon/atlas_daemon/atlas_cli.py @@ -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 [] diff --git a/tools/atlas-daemon/atlas_daemon/bot.py b/tools/atlas-daemon/atlas_daemon/bot.py new file mode 100644 index 00000000..d9023704 --- /dev/null +++ b/tools/atlas-daemon/atlas_daemon/bot.py @@ -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) diff --git a/tools/atlas-daemon/atlas_daemon/commands/__init__.py b/tools/atlas-daemon/atlas_daemon/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/atlas-daemon/atlas_daemon/config.py b/tools/atlas-daemon/atlas_daemon/config.py new file mode 100644 index 00000000..8c850d6c --- /dev/null +++ b/tools/atlas-daemon/atlas_daemon/config.py @@ -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"), + ) diff --git a/tools/atlas-daemon/atlas_daemon/daemon.py b/tools/atlas-daemon/atlas_daemon/daemon.py new file mode 100644 index 00000000..64287de0 --- /dev/null +++ b/tools/atlas-daemon/atlas_daemon/daemon.py @@ -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)) diff --git a/tools/atlas-daemon/atlas_daemon/db.py b/tools/atlas-daemon/atlas_daemon/db.py new file mode 100644 index 00000000..11eb9083 --- /dev/null +++ b/tools/atlas-daemon/atlas_daemon/db.py @@ -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 diff --git a/tools/atlas-daemon/atlas_daemon/discord_fmt.py b/tools/atlas-daemon/atlas_daemon/discord_fmt.py new file mode 100644 index 00000000..ca98df58 --- /dev/null +++ b/tools/atlas-daemon/atlas_daemon/discord_fmt.py @@ -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 diff --git a/tools/atlas-daemon/atlas_daemon/review.py b/tools/atlas-daemon/atlas_daemon/review.py new file mode 100644 index 00000000..c9a8042d --- /dev/null +++ b/tools/atlas-daemon/atlas_daemon/review.py @@ -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] diff --git a/tools/atlas-daemon/atlas_daemon/review_prompt.txt b/tools/atlas-daemon/atlas_daemon/review_prompt.txt new file mode 100644 index 00000000..760b753d --- /dev/null +++ b/tools/atlas-daemon/atlas_daemon/review_prompt.txt @@ -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" + } +] diff --git a/tools/atlas-daemon/atlas_daemon/security.py b/tools/atlas-daemon/atlas_daemon/security.py new file mode 100644 index 00000000..777d7466 --- /dev/null +++ b/tools/atlas-daemon/atlas_daemon/security.py @@ -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() diff --git a/tools/atlas-daemon/pyproject.toml b/tools/atlas-daemon/pyproject.toml new file mode 100644 index 00000000..87e140c4 --- /dev/null +++ b/tools/atlas-daemon/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "atlas-daemon" +version = "0.1.0" +description = "Discord bot daemon for automated Roblox game code review" +requires-python = ">=3.11" +dependencies = [ + "discord.py>=2.4", + "anthropic>=0.40", + "cryptography>=43", + "click>=8.1", +] + +[project.scripts] +atlas-daemon = "atlas_daemon.__main__:main"