mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-22 21:41:37 +00:00
Enhance Roblox API integration and refactor CLI commands
- Updated the `clap` dependency in `Cargo.toml` to include the "env" feature for improved command-line argument handling. - Introduced a new `roblox_api` module to encapsulate Roblox API interactions, including authentication and asset downloading. - Refactored the `CloneCommand` and `StudioCommand` to utilize the new `roblox_api` functions for fetching experience names and universe IDs, enhancing code clarity and reducing duplication. - Removed obsolete functions related to Roblox API calls from the CLI commands, streamlining the codebase. - Added error handling for authentication failures, ensuring better user feedback when required credentials are missing. These changes aim to improve the overall structure and functionality of the CLI while enhancing the integration with the Roblox API.
This commit is contained in:
parent
fb46515913
commit
9934094dbc
22 changed files with 2427 additions and 161 deletions
646
.cursor/plans/atlas_daemon_python_bot_be6b0651.plan.md
Normal file
646
.cursor/plans/atlas_daemon_python_bot_be6b0651.plan.md
Normal file
|
|
@ -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<String>,
|
||||||
|
```
|
||||||
|
|
||||||
|
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<RobloxAuth>;
|
||||||
|
|
||||||
|
/// 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<NamedTempFile>;
|
||||||
|
|
||||||
|
/// Resolve place_id -> universe_id. Works with both auth methods.
|
||||||
|
pub fn get_universe_id(place_id: u64, auth: &RobloxAuth) -> anyhow::Result<Option<u64>>;
|
||||||
|
|
||||||
|
/// 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<Option<String>>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**OpenCloud download path** (when `RobloxAuth::ApiKey`):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://apis.roblox.com/asset-delivery-api/v1/assetId/{place_id}
|
||||||
|
Header: x-api-key: <key>
|
||||||
|
Scope required: legacy-asset:manage
|
||||||
|
Response: JSON { "location": "<cdn_url>" }
|
||||||
|
-> GET <cdn_url> -> 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 <server_id> (global admin only)
|
||||||
|
add_game.py -- /add-game <place_id> [#channel]
|
||||||
|
remove_game.py -- /remove-game <place_id>
|
||||||
|
reset_game.py -- /reset-game <place_id>
|
||||||
|
set_api_key.py -- /set-api-key <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 <server_id>` (run from any approved server) | Global admin only | Adds a remote server by ID |
|
||||||
|
| `/revoke-server <server_id>` | 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 `<server_id>/<place_id>`. 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=<bot token>
|
||||||
|
ATLAS_DAEMON_ANTHROPIC_KEY=<claude api key>
|
||||||
|
ATLAS_DAEMON_DATA_DIR=./atlas-daemon-data
|
||||||
|
ATLAS_DAEMON_ENCRYPTION_KEY=<fernet 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. |
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -104,7 +104,7 @@ tracing = "0.1"
|
||||||
tracing-appender = "0.2"
|
tracing-appender = "0.2"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "time"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "time"] }
|
||||||
uuid = { version = "1.21.0", features = ["v4", "serde"] }
|
uuid = { version = "1.21.0", features = ["v4", "serde"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
clap_complete = "4"
|
clap_complete = "4"
|
||||||
profiling = "1.0.17"
|
profiling = "1.0.17"
|
||||||
yaml-rust2 = "0.11.0"
|
yaml-rust2 = "0.11.0"
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@ use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{bail, Context};
|
use anyhow::{bail, Context};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use reqwest::blocking::Client;
|
|
||||||
use reqwest::header::COOKIE;
|
use crate::roblox_api;
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use super::init::{InitCommand, InitKind};
|
use super::init::{InitCommand, InitKind};
|
||||||
use super::syncback::SyncbackCommand;
|
use super::syncback::SyncbackCommand;
|
||||||
|
|
@ -46,7 +45,15 @@ impl CloneCommand {
|
||||||
let path = match self.path {
|
let path = match self.path {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
let name = fetch_experience_name(self.placeid)?;
|
let auth = roblox_api::try_resolve_auth(global.opencloud.as_deref());
|
||||||
|
let name = match auth {
|
||||||
|
Some(a) => roblox_api::fetch_experience_name(self.placeid, &a)?
|
||||||
|
.context("Could not fetch experience name from Roblox API")?,
|
||||||
|
None => bail!(
|
||||||
|
"No --opencloud API key or Roblox cookie available to fetch experience name. \
|
||||||
|
Use --path to specify a folder manually, or pass --opencloud <KEY>."
|
||||||
|
),
|
||||||
|
};
|
||||||
let folder = sanitize_name(&name);
|
let folder = sanitize_name(&name);
|
||||||
if folder.is_empty() {
|
if folder.is_empty() {
|
||||||
bail!(
|
bail!(
|
||||||
|
|
@ -107,66 +114,6 @@ impl CloneCommand {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct UniverseResponse {
|
|
||||||
#[serde(rename = "universeId")]
|
|
||||||
universe_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct GamesResponse {
|
|
||||||
data: Vec<GameData>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct GameData {
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves a place ID to its experience name via the Roblox API.
|
|
||||||
fn fetch_experience_name(place_id: u64) -> anyhow::Result<String> {
|
|
||||||
let cookie_header = rbx_cookie::get_value().map(|c| format!(".ROBLOSECURITY={}", c));
|
|
||||||
|
|
||||||
let client = Client::new();
|
|
||||||
|
|
||||||
// Place ID -> Universe ID
|
|
||||||
let universe_url = format!("https://apis.roblox.com/universes/v1/places/{place_id}/universe");
|
|
||||||
let mut req = client.get(&universe_url);
|
|
||||||
if let Some(cookie) = &cookie_header {
|
|
||||||
req = req.header(COOKIE, cookie);
|
|
||||||
}
|
|
||||||
let universe: UniverseResponse = req
|
|
||||||
.send()
|
|
||||||
.context("Failed to reach Roblox universe API")?
|
|
||||||
.json()
|
|
||||||
.context("Failed to parse universe response")?;
|
|
||||||
|
|
||||||
// Universe ID -> Game info
|
|
||||||
let games_url = format!(
|
|
||||||
"https://games.roblox.com/v1/games?universeIds={}",
|
|
||||||
universe.universe_id
|
|
||||||
);
|
|
||||||
let mut req = client.get(&games_url);
|
|
||||||
if let Some(cookie) = &cookie_header {
|
|
||||||
req = req.header(COOKIE, cookie);
|
|
||||||
}
|
|
||||||
let games: GamesResponse = req
|
|
||||||
.send()
|
|
||||||
.context("Failed to reach Roblox games API")?
|
|
||||||
.json()
|
|
||||||
.context("Failed to parse games response")?;
|
|
||||||
|
|
||||||
let game = games
|
|
||||||
.data
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.context("No game data returned from Roblox")?;
|
|
||||||
|
|
||||||
println!("Experience: {}", game.name);
|
|
||||||
|
|
||||||
Ok(game.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sanitize a Roblox experience name into a valid folder name.
|
/// Sanitize a Roblox experience name into a valid folder name.
|
||||||
///
|
///
|
||||||
/// 1. Strip `[...]` and `(...)` sections
|
/// 1. Strip `[...]` and `(...)` sections
|
||||||
|
|
|
||||||
|
|
@ -58,13 +58,13 @@ impl Options {
|
||||||
Subcommand::Init(subcommand) => subcommand.run(),
|
Subcommand::Init(subcommand) => subcommand.run(),
|
||||||
Subcommand::Serve(subcommand) => subcommand.run(),
|
Subcommand::Serve(subcommand) => subcommand.run(),
|
||||||
Subcommand::Build(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::Sourcemap(subcommand) => subcommand.run(),
|
||||||
Subcommand::FmtProject(subcommand) => subcommand.run(),
|
Subcommand::FmtProject(subcommand) => subcommand.run(),
|
||||||
Subcommand::Cursor(subcommand) => subcommand.run(),
|
Subcommand::Cursor(subcommand) => subcommand.run(),
|
||||||
Subcommand::Doc(subcommand) => subcommand.run(),
|
Subcommand::Doc(subcommand) => subcommand.run(),
|
||||||
Subcommand::Plugin(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::Syncback(subcommand) | Subcommand::Pull(subcommand) => {
|
||||||
subcommand.run(self.global)
|
subcommand.run(self.global)
|
||||||
}
|
}
|
||||||
|
|
@ -81,6 +81,11 @@ pub struct GlobalOptions {
|
||||||
/// Set color behavior. Valid values are auto, always, and never.
|
/// Set color behavior. Valid values are auto, always, and never.
|
||||||
#[clap(long("color"), global(true), default_value("auto"))]
|
#[clap(long("color"), global(true), default_value("auto"))]
|
||||||
pub color: ColorChoice,
|
pub color: ColorChoice,
|
||||||
|
|
||||||
|
/// OpenCloud API key for Roblox API access (alternative to cookie auth).
|
||||||
|
/// Requires the 'legacy-asset:manage' scope for downloading places.
|
||||||
|
#[clap(long, env = "ATLAS_OPENCLOUD_KEY", global(true), hide = true)]
|
||||||
|
pub opencloud: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,12 @@ use std::process::Command;
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use memofs::Vfs;
|
use memofs::Vfs;
|
||||||
use reqwest::header::COOKIE;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::project::Project;
|
use crate::project::Project;
|
||||||
|
use crate::roblox_api;
|
||||||
|
|
||||||
use super::resolve_path;
|
use super::resolve_path;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct UniverseResponse {
|
|
||||||
#[serde(rename = "universeId")]
|
|
||||||
universe_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open a Rojo project in Roblox Studio.
|
/// Open a Rojo project in Roblox Studio.
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
pub struct StudioCommand {
|
pub struct StudioCommand {
|
||||||
|
|
@ -27,7 +20,7 @@ pub struct StudioCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StudioCommand {
|
impl StudioCommand {
|
||||||
pub fn run(self) -> anyhow::Result<()> {
|
pub fn run(self, global: super::GlobalOptions) -> anyhow::Result<()> {
|
||||||
let vfs = Vfs::new_oneshot();
|
let vfs = Vfs::new_oneshot();
|
||||||
|
|
||||||
let base_path = resolve_path(&self.project);
|
let base_path = resolve_path(&self.project);
|
||||||
|
|
@ -45,14 +38,17 @@ impl StudioCommand {
|
||||||
.copied()
|
.copied()
|
||||||
.context("servePlaceIds is empty in project file")?;
|
.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!(
|
let url = format!(
|
||||||
"roblox-studio:1+launchmode:edit+task:EditPlace+placeId:{}+universeId:{}",
|
"roblox-studio:1+launchmode:edit+task:EditPlace+placeId:{}+universeId:{}",
|
||||||
place_id, universe_id
|
place_id, universe_id
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use cmd /c start to fully detach the process on Windows
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
Command::new("cmd")
|
Command::new("cmd")
|
||||||
.args(["/c", "start", "", &url])
|
.args(["/c", "start", "", &url])
|
||||||
|
|
@ -65,24 +61,3 @@ impl StudioCommand {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_universe_id(place_id: u64) -> anyhow::Result<u64> {
|
|
||||||
let cookie_header = rbx_cookie::get_value().map(|c| format!(".ROBLOSECURITY={}", c));
|
|
||||||
|
|
||||||
let url = format!(
|
|
||||||
"https://apis.roblox.com/universes/v1/places/{}/universe",
|
|
||||||
place_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut req = reqwest::blocking::Client::new().get(&url);
|
|
||||||
if let Some(cookie) = &cookie_header {
|
|
||||||
req = req.header(COOKIE, cookie);
|
|
||||||
}
|
|
||||||
let response: UniverseResponse = req
|
|
||||||
.send()
|
|
||||||
.context("Failed to fetch universe ID from Roblox API")?
|
|
||||||
.json()
|
|
||||||
.context("Failed to parse universe ID response")?;
|
|
||||||
|
|
||||||
Ok(response.universe_id)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,18 @@ use std::{
|
||||||
time::Instant,
|
time::Instant,
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{bail, Context};
|
use anyhow::Context;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use fs_err::File;
|
use fs_err::File;
|
||||||
use memofs::Vfs;
|
use memofs::Vfs;
|
||||||
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
||||||
use reqwest::header::{CACHE_CONTROL, COOKIE, PRAGMA, USER_AGENT};
|
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor};
|
use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
path_serializer::display_absolute,
|
path_serializer::display_absolute,
|
||||||
project::Project,
|
project::Project,
|
||||||
|
roblox_api,
|
||||||
serve_session::ServeSession,
|
serve_session::ServeSession,
|
||||||
syncback::{syncback_loop, FsSnapshot},
|
syncback::{syncback_loop, FsSnapshot},
|
||||||
};
|
};
|
||||||
|
|
@ -109,7 +109,8 @@ impl SyncbackCommand {
|
||||||
// --download=PLACEID: always download this specific place
|
// --download=PLACEID: always download this specific place
|
||||||
log::info!("Downloading place {}...", place_id);
|
log::info!("Downloading place {}...", place_id);
|
||||||
let download_timer = Instant::now();
|
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!(
|
log::info!(
|
||||||
"Downloaded in {:.02}s",
|
"Downloaded in {:.02}s",
|
||||||
download_timer.elapsed().as_secs_f32()
|
download_timer.elapsed().as_secs_f32()
|
||||||
|
|
@ -139,7 +140,8 @@ impl SyncbackCommand {
|
||||||
place_id
|
place_id
|
||||||
);
|
);
|
||||||
let download_timer = Instant::now();
|
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!(
|
log::info!(
|
||||||
"Downloaded in {:.02}s",
|
"Downloaded in {:.02}s",
|
||||||
download_timer.elapsed().as_secs_f32()
|
download_timer.elapsed().as_secs_f32()
|
||||||
|
|
@ -335,61 +337,6 @@ fn get_place_id_from_project(project_path: &Path) -> anyhow::Result<u64> {
|
||||||
.context("servePlaceIds is empty in project file")
|
.context("servePlaceIds is empty in project file")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads a place file from Roblox's asset delivery API.
|
|
||||||
///
|
|
||||||
/// Uses rbx_cookie to get the authentication cookie from the system.
|
|
||||||
fn download_place(place_id: u64) -> anyhow::Result<NamedTempFile> {
|
|
||||||
let cookie = rbx_cookie::get_value()
|
|
||||||
.context("Could not find Roblox authentication cookie. Please log into Roblox Studio.")?;
|
|
||||||
|
|
||||||
let url = format!("https://assetdelivery.roblox.com/v1/asset/?id={}", place_id);
|
|
||||||
|
|
||||||
let client = reqwest::blocking::Client::builder()
|
|
||||||
.gzip(true)
|
|
||||||
.brotli(true)
|
|
||||||
.deflate(true)
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(&url)
|
|
||||||
.header(COOKIE, format!(".ROBLOSECURITY={}", cookie))
|
|
||||||
.header(CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
|
||||||
.header(PRAGMA, "no-cache")
|
|
||||||
.header("Expires", "0")
|
|
||||||
.header(USER_AGENT, "Rojo")
|
|
||||||
.send()?;
|
|
||||||
|
|
||||||
let status = response.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
bail!(
|
|
||||||
"Failed to download place {}: HTTP {} - {}",
|
|
||||||
place_id,
|
|
||||||
status,
|
|
||||||
response.text().unwrap_or_default()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create temp file with .rbxl extension
|
|
||||||
let mut temp_file = tempfile::Builder::new()
|
|
||||||
.prefix("rojo-syncback-")
|
|
||||||
.suffix(".rbxl")
|
|
||||||
.tempfile()
|
|
||||||
.context("Failed to create temporary file")?;
|
|
||||||
|
|
||||||
// Write response body to temp file
|
|
||||||
let bytes = response.bytes()?;
|
|
||||||
io::copy(&mut bytes.as_ref(), &mut temp_file)?;
|
|
||||||
temp_file.flush()?;
|
|
||||||
|
|
||||||
log::debug!(
|
|
||||||
"Downloaded {} bytes to {}",
|
|
||||||
bytes.len(),
|
|
||||||
temp_file.path().display()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(temp_file)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_dom(path: &Path, file_kind: FileKind) -> anyhow::Result<WeakDom> {
|
fn read_dom(path: &Path, file_kind: FileKind) -> anyhow::Result<WeakDom> {
|
||||||
let content = BufReader::new(File::open(path)?);
|
let content = BufReader::new(File::open(path)?);
|
||||||
match file_kind {
|
match file_kind {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use serde_json::Value;
|
||||||
|
|
||||||
use crate::serve_session::ServeSession;
|
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 ASSETS_API_BASE: &str = "https://apis.roblox.com/assets/v1";
|
||||||
const MAX_OPERATION_RETRIES: u32 = 10;
|
const MAX_OPERATION_RETRIES: u32 = 10;
|
||||||
|
|
@ -50,7 +50,7 @@ pub struct UploadCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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 project_path = resolve_path(&self.project);
|
||||||
|
|
||||||
let vfs = Vfs::new_default();
|
let vfs = Vfs::new_default();
|
||||||
|
|
@ -71,7 +71,9 @@ impl UploadCommand {
|
||||||
log::trace!("Encoding binary model");
|
log::trace!("Encoding binary model");
|
||||||
rbx_binary::to_writer(&mut buffer, tree.inner(), &encode_ids)?;
|
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) => {
|
(cookie, None, universe) => {
|
||||||
// Legacy cookie auth
|
// Legacy cookie auth
|
||||||
if universe.is_some() {
|
if universe.is_some() {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ mod multimap;
|
||||||
mod path_serializer;
|
mod path_serializer;
|
||||||
mod project;
|
mod project;
|
||||||
mod resolution;
|
mod resolution;
|
||||||
|
pub mod roblox_api;
|
||||||
mod rojo_ref;
|
mod rojo_ref;
|
||||||
mod serve_session;
|
mod serve_session;
|
||||||
mod session_id;
|
mod session_id;
|
||||||
|
|
|
||||||
261
src/roblox_api.rs
Normal file
261
src/roblox_api.rs
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
use std::io::{self, Write as _};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context};
|
||||||
|
use reqwest::header::{CACHE_CONTROL, COOKIE, PRAGMA, USER_AGENT};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
/// Authentication method for Roblox API calls.
|
||||||
|
pub enum RobloxAuth {
|
||||||
|
Cookie(String),
|
||||||
|
ApiKey(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve authentication: prefer `--opencloud` API key, fall back to system cookie.
|
||||||
|
pub fn resolve_auth(opencloud_key: Option<&str>) -> anyhow::Result<RobloxAuth> {
|
||||||
|
if let Some(key) = opencloud_key {
|
||||||
|
return Ok(RobloxAuth::ApiKey(key.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let cookie = rbx_cookie::get_value().context(
|
||||||
|
"No OpenCloud API key (--opencloud) or Roblox auth cookie found. \
|
||||||
|
Pass --opencloud <KEY> or log into Roblox Studio.",
|
||||||
|
)?;
|
||||||
|
Ok(RobloxAuth::Cookie(cookie))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to resolve authentication, returning `None` if neither key nor cookie is available.
|
||||||
|
pub fn try_resolve_auth(opencloud_key: Option<&str>) -> Option<RobloxAuth> {
|
||||||
|
if let Some(key) = opencloud_key {
|
||||||
|
return Some(RobloxAuth::ApiKey(key.to_string()));
|
||||||
|
}
|
||||||
|
rbx_cookie::get_value().map(RobloxAuth::Cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download a place file from Roblox.
|
||||||
|
///
|
||||||
|
/// With API key: uses OpenCloud Asset Delivery API (`apis.roblox.com/asset-delivery-api`).
|
||||||
|
/// With cookie: uses legacy asset delivery (`assetdelivery.roblox.com`).
|
||||||
|
pub fn download_place(place_id: u64, auth: &RobloxAuth) -> anyhow::Result<NamedTempFile> {
|
||||||
|
let bytes = match auth {
|
||||||
|
RobloxAuth::ApiKey(key) => download_place_opencloud(place_id, key)?,
|
||||||
|
RobloxAuth::Cookie(cookie) => download_place_cookie(place_id, cookie)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut temp_file = tempfile::Builder::new()
|
||||||
|
.prefix("rojo-syncback-")
|
||||||
|
.suffix(".rbxl")
|
||||||
|
.tempfile()
|
||||||
|
.context("Failed to create temporary file")?;
|
||||||
|
|
||||||
|
io::copy(&mut bytes.as_slice(), &mut temp_file)?;
|
||||||
|
temp_file.flush()?;
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"Downloaded {} bytes to {}",
|
||||||
|
bytes.len(),
|
||||||
|
temp_file.path().display()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(temp_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_place_opencloud(place_id: u64, api_key: &str) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let url = format!(
|
||||||
|
"https://apis.roblox.com/asset-delivery-api/v1/assetId/{}",
|
||||||
|
place_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.gzip(true)
|
||||||
|
.brotli(true)
|
||||||
|
.deflate(true)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.header("x-api-key", api_key)
|
||||||
|
.header("Accept-Encoding", "gzip")
|
||||||
|
.send()?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
bail!(
|
||||||
|
"Failed to download place {} via OpenCloud: HTTP {} - {}",
|
||||||
|
place_id,
|
||||||
|
status,
|
||||||
|
response.text().unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: AssetDeliveryResponse = response.json().with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Failed to parse OpenCloud asset delivery response for place {}",
|
||||||
|
place_id
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let cdn_url = body.location.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"OpenCloud asset delivery response for place {} has no location URL. \
|
||||||
|
Ensure the API key has the 'legacy-asset:manage' scope.",
|
||||||
|
place_id
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let cdn_response = client.get(&cdn_url).send()?;
|
||||||
|
|
||||||
|
let cdn_status = cdn_response.status();
|
||||||
|
if !cdn_status.is_success() {
|
||||||
|
bail!(
|
||||||
|
"Failed to download place {} from CDN: HTTP {} - {}",
|
||||||
|
place_id,
|
||||||
|
cdn_status,
|
||||||
|
cdn_response.text().unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(cdn_response.bytes()?.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_place_cookie(place_id: u64, cookie: &str) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let url = format!("https://assetdelivery.roblox.com/v1/asset/?id={}", place_id);
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.gzip(true)
|
||||||
|
.brotli(true)
|
||||||
|
.deflate(true)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.header(COOKIE, format!(".ROBLOSECURITY={}", cookie))
|
||||||
|
.header(CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
||||||
|
.header(PRAGMA, "no-cache")
|
||||||
|
.header("Expires", "0")
|
||||||
|
.header(USER_AGENT, "Rojo")
|
||||||
|
.send()?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
bail!(
|
||||||
|
"Failed to download place {}: HTTP {} - {}",
|
||||||
|
place_id,
|
||||||
|
status,
|
||||||
|
response.text().unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response.bytes()?.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AssetDeliveryResponse {
|
||||||
|
location: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct UniverseResponse {
|
||||||
|
#[serde(rename = "universeId")]
|
||||||
|
universe_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GamesResponse {
|
||||||
|
data: Vec<GameData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GameData {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a place ID to a universe ID.
|
||||||
|
///
|
||||||
|
/// Returns `None` if no auth is available. Works with both API key and cookie.
|
||||||
|
pub fn get_universe_id(place_id: u64, auth: &RobloxAuth) -> anyhow::Result<u64> {
|
||||||
|
let url = format!(
|
||||||
|
"https://apis.roblox.com/universes/v1/places/{}/universe",
|
||||||
|
place_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let req = apply_auth(client.get(&url), auth);
|
||||||
|
|
||||||
|
let response: UniverseResponse = req
|
||||||
|
.send()
|
||||||
|
.context("Failed to fetch universe ID from Roblox API")?
|
||||||
|
.json()
|
||||||
|
.context("Failed to parse universe ID response")?;
|
||||||
|
|
||||||
|
Ok(response.universe_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a place ID to its experience name via the Roblox API.
|
||||||
|
///
|
||||||
|
/// With cookie auth, uses both the universe API and the games API.
|
||||||
|
/// With API key, the games.roblox.com endpoint may not support API key auth,
|
||||||
|
/// so this falls back gracefully.
|
||||||
|
pub fn fetch_experience_name(place_id: u64, auth: &RobloxAuth) -> anyhow::Result<Option<String>> {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
|
||||||
|
let universe_url = format!(
|
||||||
|
"https://apis.roblox.com/universes/v1/places/{}/universe",
|
||||||
|
place_id
|
||||||
|
);
|
||||||
|
let req = apply_auth(client.get(&universe_url), auth);
|
||||||
|
let universe: UniverseResponse = req
|
||||||
|
.send()
|
||||||
|
.context("Failed to reach Roblox universe API")?
|
||||||
|
.json()
|
||||||
|
.context("Failed to parse universe response")?;
|
||||||
|
|
||||||
|
let games_url = format!(
|
||||||
|
"https://games.roblox.com/v1/games?universeIds={}",
|
||||||
|
universe.universe_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// games.roblox.com may not support API key auth; try with auth, fall back to no-auth
|
||||||
|
let req = apply_auth(client.get(&games_url), auth);
|
||||||
|
let games_result: Result<GamesResponse, _> = req.send().and_then(|r| r.json());
|
||||||
|
|
||||||
|
match games_result {
|
||||||
|
Ok(games) => {
|
||||||
|
if let Some(game) = games.data.into_iter().next() {
|
||||||
|
println!("Experience: {}", game.name);
|
||||||
|
return Ok(Some(game.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!(
|
||||||
|
"Could not fetch game name (games API may not support this auth method): {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If API key was used and games endpoint failed, try without auth
|
||||||
|
if matches!(auth, RobloxAuth::ApiKey(_)) {
|
||||||
|
let req = client.get(&games_url);
|
||||||
|
if let Ok(resp) = req.send() {
|
||||||
|
if let Ok(games) = resp.json::<GamesResponse>() {
|
||||||
|
if let Some(game) = games.data.into_iter().next() {
|
||||||
|
println!("Experience: {}", game.name);
|
||||||
|
return Ok(Some(game.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_auth(
|
||||||
|
req: reqwest::blocking::RequestBuilder,
|
||||||
|
auth: &RobloxAuth,
|
||||||
|
) -> reqwest::blocking::RequestBuilder {
|
||||||
|
match auth {
|
||||||
|
RobloxAuth::ApiKey(key) => req.header("x-api-key", key),
|
||||||
|
RobloxAuth::Cookie(cookie) => req.header(COOKIE, format!(".ROBLOSECURITY={}", cookie)),
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tools/atlas-daemon/atlas_daemon/__init__.py
Normal file
13
tools/atlas-daemon/atlas_daemon/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .db import Database
|
||||||
|
|
||||||
|
|
||||||
|
class _BotRef:
|
||||||
|
db: Database | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_bot_ref = _BotRef()
|
||||||
67
tools/atlas-daemon/atlas_daemon/__main__.py
Normal file
67
tools/atlas-daemon/atlas_daemon/__main__.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from .bot import AtlasDaemonBot
|
||||||
|
from .config import Config
|
||||||
|
from .db import Database
|
||||||
|
from .security import init_encryption
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option("--discord-token", envvar="ATLAS_DAEMON_DISCORD_TOKEN", default="", help="Discord bot token")
|
||||||
|
@click.option("--anthropic-key", envvar="ATLAS_DAEMON_ANTHROPIC_KEY", default="", help="Anthropic API key")
|
||||||
|
@click.option("--data-dir", envvar="ATLAS_DAEMON_DATA_DIR", default="./atlas-daemon-data", help="Data directory")
|
||||||
|
@click.option("--encryption-key", envvar="ATLAS_DAEMON_ENCRYPTION_KEY", default="", help="Fernet encryption key")
|
||||||
|
@click.option("--sync-interval", envvar="ATLAS_DAEMON_SYNC_INTERVAL", default=300, type=int, help="Sync interval (seconds)")
|
||||||
|
@click.option("--atlas-binary", envvar="ATLAS_DAEMON_ATLAS_BINARY", default="atlas", help="Path to atlas binary")
|
||||||
|
def main(
|
||||||
|
discord_token: str,
|
||||||
|
anthropic_key: str,
|
||||||
|
data_dir: str,
|
||||||
|
encryption_key: str,
|
||||||
|
sync_interval: int,
|
||||||
|
atlas_binary: str,
|
||||||
|
) -> None:
|
||||||
|
"""Atlas Daemon - Automated Roblox Code Review Bot"""
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not discord_token:
|
||||||
|
click.echo("Error: Discord token is required (--discord-token or ATLAS_DAEMON_DISCORD_TOKEN)", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
if not anthropic_key:
|
||||||
|
click.echo("Error: Anthropic key is required (--anthropic-key or ATLAS_DAEMON_ANTHROPIC_KEY)", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
config = Config.from_env(
|
||||||
|
discord_token=discord_token,
|
||||||
|
anthropic_key=anthropic_key,
|
||||||
|
data_dir=data_dir,
|
||||||
|
encryption_key=encryption_key,
|
||||||
|
sync_interval=sync_interval,
|
||||||
|
atlas_binary=atlas_binary,
|
||||||
|
)
|
||||||
|
|
||||||
|
config.data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
init_encryption(config.encryption_key)
|
||||||
|
|
||||||
|
db = Database(config.data_dir / "atlas-daemon.db")
|
||||||
|
|
||||||
|
import anthropic as anthropic_mod
|
||||||
|
|
||||||
|
claude = anthropic_mod.AsyncAnthropic(api_key=config.anthropic_key)
|
||||||
|
|
||||||
|
bot = AtlasDaemonBot(config, db, claude)
|
||||||
|
bot.run(config.discord_token)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
101
tools/atlas-daemon/atlas_daemon/atlas_cli.py
Normal file
101
tools/atlas-daemon/atlas_daemon/atlas_cli.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(
|
||||||
|
*cmd: str, cwd: Path | None = None, timeout: float = 300
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=str(cwd) if cwd else None,
|
||||||
|
)
|
||||||
|
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||||
|
proc.communicate(), timeout=timeout
|
||||||
|
)
|
||||||
|
stdout = stdout_bytes.decode(errors="replace") if stdout_bytes else ""
|
||||||
|
stderr = stderr_bytes.decode(errors="replace") if stderr_bytes else ""
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Command failed (exit {proc.returncode}): {' '.join(cmd)}\n{stderr}"
|
||||||
|
)
|
||||||
|
return stdout, stderr
|
||||||
|
|
||||||
|
|
||||||
|
async def atlas_clone(
|
||||||
|
atlas_binary: str,
|
||||||
|
place_id: int,
|
||||||
|
path: Path,
|
||||||
|
opencloud_key: str,
|
||||||
|
) -> None:
|
||||||
|
log.info("Cloning place %d into %s", place_id, path)
|
||||||
|
await _run(
|
||||||
|
atlas_binary,
|
||||||
|
"--opencloud",
|
||||||
|
opencloud_key,
|
||||||
|
"clone",
|
||||||
|
str(place_id),
|
||||||
|
"--path",
|
||||||
|
str(path),
|
||||||
|
"--skip-git",
|
||||||
|
"--skip-rules",
|
||||||
|
timeout=600,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def atlas_syncback(
|
||||||
|
atlas_binary: str,
|
||||||
|
working_dir: Path,
|
||||||
|
place_id: int,
|
||||||
|
opencloud_key: str,
|
||||||
|
) -> str:
|
||||||
|
log.info("Syncing place %d in %s", place_id, working_dir)
|
||||||
|
stdout, _ = await _run(
|
||||||
|
atlas_binary,
|
||||||
|
"--opencloud",
|
||||||
|
opencloud_key,
|
||||||
|
"syncback",
|
||||||
|
"--download",
|
||||||
|
str(place_id),
|
||||||
|
"--working-dir",
|
||||||
|
str(working_dir),
|
||||||
|
"--incremental",
|
||||||
|
"--list",
|
||||||
|
timeout=600,
|
||||||
|
)
|
||||||
|
return stdout
|
||||||
|
|
||||||
|
|
||||||
|
async def git_init(cwd: Path) -> None:
|
||||||
|
await _run("git", "init", cwd=cwd)
|
||||||
|
|
||||||
|
|
||||||
|
async def git_add_all(cwd: Path) -> None:
|
||||||
|
await _run("git", "add", ".", cwd=cwd)
|
||||||
|
|
||||||
|
|
||||||
|
async def git_commit(cwd: Path, message: str) -> None:
|
||||||
|
await _run("git", "commit", "-m", message, "--allow-empty", cwd=cwd)
|
||||||
|
|
||||||
|
|
||||||
|
async def git_diff(cwd: Path) -> str:
|
||||||
|
try:
|
||||||
|
stdout, _ = await _run("git", "diff", "HEAD", cwd=cwd)
|
||||||
|
return stdout
|
||||||
|
except RuntimeError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def git_diff_name_only(cwd: Path) -> list[str]:
|
||||||
|
try:
|
||||||
|
stdout, _ = await _run("git", "diff", "HEAD", "--name-only", cwd=cwd)
|
||||||
|
return [line.strip() for line in stdout.splitlines() if line.strip()]
|
||||||
|
except RuntimeError:
|
||||||
|
return []
|
||||||
307
tools/atlas-daemon/atlas_daemon/bot.py
Normal file
307
tools/atlas-daemon/atlas_daemon/bot.py
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import anthropic
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
from . import _bot_ref, daemon, security
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .config import Config
|
||||||
|
from .db import Database
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AtlasDaemonBot(discord.Client):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: Config,
|
||||||
|
db: Database,
|
||||||
|
anthropic_client: anthropic.AsyncAnthropic,
|
||||||
|
) -> None:
|
||||||
|
intents = discord.Intents.default()
|
||||||
|
intents.guilds = True
|
||||||
|
intents.guild_messages = True
|
||||||
|
super().__init__(intents=intents)
|
||||||
|
|
||||||
|
self.config = config
|
||||||
|
self.db = db
|
||||||
|
self.claude = anthropic_client
|
||||||
|
self.tree = app_commands.CommandTree(self)
|
||||||
|
|
||||||
|
_bot_ref.db = db
|
||||||
|
|
||||||
|
self._register_commands()
|
||||||
|
|
||||||
|
def _register_commands(self) -> None:
|
||||||
|
tree = self.tree
|
||||||
|
|
||||||
|
@tree.command(name="approve-server", description="Whitelist this server (or a remote one by ID) to use the bot")
|
||||||
|
@app_commands.describe(server_id="Optional: remote server ID to approve")
|
||||||
|
async def approve_server(interaction: discord.Interaction, server_id: str | None = None) -> None:
|
||||||
|
if not self.db.is_admin(str(interaction.user.id)):
|
||||||
|
await interaction.response.send_message("Only bot admins can approve servers.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
target_id = server_id or str(interaction.guild_id)
|
||||||
|
server_name = None
|
||||||
|
if not server_id and interaction.guild:
|
||||||
|
server_name = interaction.guild.name
|
||||||
|
|
||||||
|
self.db.approve_server(target_id, server_name, str(interaction.user.id))
|
||||||
|
await interaction.response.send_message(f"Server `{target_id}` approved.", ephemeral=True)
|
||||||
|
|
||||||
|
@tree.command(name="revoke-server", description="Remove a server from the whitelist")
|
||||||
|
@app_commands.describe(server_id="Server ID to revoke")
|
||||||
|
async def revoke_server(interaction: discord.Interaction, server_id: str) -> None:
|
||||||
|
if not self.db.is_admin(str(interaction.user.id)):
|
||||||
|
await interaction.response.send_message("Only bot admins can revoke servers.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
daemon.stop_all_for_server(self.db, server_id)
|
||||||
|
self.db.revoke_server(server_id)
|
||||||
|
await interaction.response.send_message(f"Server `{server_id}` revoked.", ephemeral=True)
|
||||||
|
|
||||||
|
@tree.command(name="set-api-key", description="Set your OpenCloud API key (requires legacy-asset:manage scope)")
|
||||||
|
@app_commands.describe(key="Your OpenCloud API key")
|
||||||
|
async def set_api_key(interaction: discord.Interaction, key: str) -> None:
|
||||||
|
if not self._check_server(interaction):
|
||||||
|
return
|
||||||
|
|
||||||
|
encrypted = security.encrypt_api_key(key)
|
||||||
|
for game in self.db.get_games_for_server(str(interaction.guild_id)):
|
||||||
|
if game.added_by == str(interaction.user.id):
|
||||||
|
self.db._conn.execute(
|
||||||
|
"UPDATE games SET api_key_encrypted = ? WHERE id = ?",
|
||||||
|
(encrypted, game.id),
|
||||||
|
)
|
||||||
|
self.db._conn.commit()
|
||||||
|
await interaction.response.send_message(
|
||||||
|
"API key updated for all your games in this server.", ephemeral=True
|
||||||
|
)
|
||||||
|
|
||||||
|
@tree.command(name="add-game", description="Start monitoring a Roblox game")
|
||||||
|
@app_commands.describe(
|
||||||
|
place_id="The Roblox Place ID to monitor",
|
||||||
|
channel="Channel to post reviews in (defaults to current)",
|
||||||
|
)
|
||||||
|
async def add_game(
|
||||||
|
interaction: discord.Interaction,
|
||||||
|
place_id: int,
|
||||||
|
channel: discord.TextChannel | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not await self._check_server_async(interaction):
|
||||||
|
return
|
||||||
|
|
||||||
|
server_id = str(interaction.guild_id)
|
||||||
|
|
||||||
|
if self.db.count_games_for_server(server_id) >= self.config.max_games_per_server:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
f"This server has reached the maximum of {self.config.max_games_per_server} monitored games.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.db.get_game(server_id, place_id):
|
||||||
|
await interaction.response.send_message(
|
||||||
|
f"PlaceId {place_id} is already being monitored in this server.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
|
||||||
|
target_channel = channel or interaction.channel
|
||||||
|
if not isinstance(target_channel, discord.TextChannel):
|
||||||
|
await interaction.followup.send("Please specify a text channel.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
games = self.db.get_games_for_server(server_id)
|
||||||
|
user_games = [g for g in games if g.added_by == str(interaction.user.id)]
|
||||||
|
api_key_encrypted: bytes | None = None
|
||||||
|
if user_games:
|
||||||
|
api_key_encrypted = user_games[0].api_key_encrypted
|
||||||
|
|
||||||
|
if api_key_encrypted is None:
|
||||||
|
await interaction.followup.send(
|
||||||
|
"No API key found. Please run `/set-api-key` first, then try again.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
opencloud_key = security.decrypt_api_key(api_key_encrypted)
|
||||||
|
game_dir = await daemon.init_game(
|
||||||
|
self.config, place_id, server_id, opencloud_key
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
await interaction.followup.send(
|
||||||
|
f"Failed to initialize game: ```{str(exc)[:1500]}```", ephemeral=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
game_id = self.db.add_game(
|
||||||
|
server_id=server_id,
|
||||||
|
place_id=place_id,
|
||||||
|
channel_id=str(target_channel.id),
|
||||||
|
api_key_encrypted=api_key_encrypted,
|
||||||
|
added_by=str(interaction.user.id),
|
||||||
|
working_dir=str(game_dir),
|
||||||
|
)
|
||||||
|
|
||||||
|
game = self.db.get_game(server_id, place_id)
|
||||||
|
if game:
|
||||||
|
daemon.start_sync_loop(game, self.config, self.db, self, self.claude)
|
||||||
|
|
||||||
|
await interaction.followup.send(
|
||||||
|
f"Now monitoring PlaceId {place_id} in {target_channel.mention}. "
|
||||||
|
f"Syncing every {self.config.sync_interval}s.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@tree.command(name="remove-game", description="Stop monitoring a game")
|
||||||
|
@app_commands.describe(place_id="The Place ID to stop monitoring")
|
||||||
|
async def remove_game(interaction: discord.Interaction, place_id: int) -> None:
|
||||||
|
if not await self._check_server_async(interaction):
|
||||||
|
return
|
||||||
|
|
||||||
|
server_id = str(interaction.guild_id)
|
||||||
|
game = self.db.get_game(server_id, place_id)
|
||||||
|
if not game:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
f"PlaceId {place_id} is not being monitored.", ephemeral=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if game.added_by != str(interaction.user.id) and not self.db.is_admin(str(interaction.user.id)):
|
||||||
|
await interaction.response.send_message("You can only remove games you added.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
daemon.stop_sync_loop(game.id)
|
||||||
|
self.db.remove_game(server_id, place_id)
|
||||||
|
await interaction.response.send_message(f"Stopped monitoring PlaceId {place_id}.", ephemeral=True)
|
||||||
|
|
||||||
|
@tree.command(name="reset-game", description="Delete local clone and re-sync from scratch")
|
||||||
|
@app_commands.describe(place_id="The Place ID to reset")
|
||||||
|
async def reset_game(interaction: discord.Interaction, place_id: int) -> None:
|
||||||
|
if not await self._check_server_async(interaction):
|
||||||
|
return
|
||||||
|
|
||||||
|
server_id = str(interaction.guild_id)
|
||||||
|
game = self.db.get_game(server_id, place_id)
|
||||||
|
if not game:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
f"PlaceId {place_id} is not being monitored.", ephemeral=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if game.added_by != str(interaction.user.id) and not self.db.is_admin(str(interaction.user.id)):
|
||||||
|
await interaction.response.send_message("You can only reset games you added.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
|
||||||
|
daemon.stop_sync_loop(game.id)
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
working_dir = Path(game.working_dir)
|
||||||
|
if working_dir.exists():
|
||||||
|
shutil.rmtree(working_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
opencloud_key = security.decrypt_api_key(game.api_key_encrypted)
|
||||||
|
await daemon.init_game(self.config, place_id, server_id, opencloud_key)
|
||||||
|
except Exception as exc:
|
||||||
|
await interaction.followup.send(
|
||||||
|
f"Failed to re-initialize: ```{str(exc)[:1500]}```", ephemeral=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.db._conn.execute(
|
||||||
|
"DELETE FROM issues WHERE game_id = ?", (game.id,)
|
||||||
|
)
|
||||||
|
self.db._conn.execute(
|
||||||
|
"UPDATE games SET last_sync_at = NULL, last_error = NULL, error_count = 0 WHERE id = ?",
|
||||||
|
(game.id,),
|
||||||
|
)
|
||||||
|
self.db._conn.commit()
|
||||||
|
|
||||||
|
game = self.db.get_game(server_id, place_id)
|
||||||
|
if game:
|
||||||
|
daemon.start_sync_loop(game, self.config, self.db, self, self.claude)
|
||||||
|
|
||||||
|
await interaction.followup.send(f"PlaceId {place_id} has been reset and re-synced.", ephemeral=True)
|
||||||
|
|
||||||
|
@tree.command(name="status", description="Show monitored games and sync status")
|
||||||
|
async def status(interaction: discord.Interaction) -> None:
|
||||||
|
if not await self._check_server_async(interaction):
|
||||||
|
return
|
||||||
|
|
||||||
|
games = self.db.get_games_for_server(str(interaction.guild_id))
|
||||||
|
if not games:
|
||||||
|
await interaction.response.send_message("No games are being monitored in this server.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for g in games:
|
||||||
|
status_icon = "🔴" if g.last_error else "🟢"
|
||||||
|
unresolved = len(self.db.get_unresolved_issues(g.id))
|
||||||
|
lines.append(
|
||||||
|
f"{status_icon} **PlaceId {g.place_id}** -- "
|
||||||
|
f"Last sync: {g.last_sync_at or 'Never'} -- "
|
||||||
|
f"Unresolved: {unresolved}"
|
||||||
|
)
|
||||||
|
if g.last_error:
|
||||||
|
error_preview = g.last_error[:100] + ("..." if len(g.last_error) > 100 else "")
|
||||||
|
lines.append(f" └ Error: {error_preview}")
|
||||||
|
|
||||||
|
await interaction.response.send_message("\n".join(lines), ephemeral=True)
|
||||||
|
|
||||||
|
@tree.command(name="list-games", description="List all monitored games in this server")
|
||||||
|
async def list_games(interaction: discord.Interaction) -> None:
|
||||||
|
if not await self._check_server_async(interaction):
|
||||||
|
return
|
||||||
|
|
||||||
|
games = self.db.get_games_for_server(str(interaction.guild_id))
|
||||||
|
if not games:
|
||||||
|
await interaction.response.send_message("No games monitored.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"• PlaceId **{g.place_id}** → <#{g.channel_id}> (added by <@{g.added_by}>)"
|
||||||
|
for g in games
|
||||||
|
]
|
||||||
|
await interaction.response.send_message("\n".join(lines), ephemeral=True)
|
||||||
|
|
||||||
|
def _check_server(self, interaction: discord.Interaction) -> bool:
|
||||||
|
if not interaction.guild_id:
|
||||||
|
return False
|
||||||
|
return self.db.is_server_allowed(str(interaction.guild_id))
|
||||||
|
|
||||||
|
async def _check_server_async(self, interaction: discord.Interaction) -> bool:
|
||||||
|
if not interaction.guild_id:
|
||||||
|
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
|
||||||
|
return False
|
||||||
|
if not self.db.is_server_allowed(str(interaction.guild_id)):
|
||||||
|
await interaction.response.send_message(
|
||||||
|
"This server is not authorized. Ask a bot admin to run `/approve-server`.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def on_ready(self) -> None:
|
||||||
|
log.info("Bot ready as %s", self.user)
|
||||||
|
await self.tree.sync()
|
||||||
|
log.info("Slash commands synced")
|
||||||
|
|
||||||
|
for game in self.db.get_all_games():
|
||||||
|
if self.db.is_server_allowed(game.discord_server_id):
|
||||||
|
daemon.start_sync_loop(game, self.config, self.db, self, self.claude)
|
||||||
|
log.info("Started sync loop for PlaceId %d", game.place_id)
|
||||||
0
tools/atlas-daemon/atlas_daemon/commands/__init__.py
Normal file
0
tools/atlas-daemon/atlas_daemon/commands/__init__.py
Normal file
34
tools/atlas-daemon/atlas_daemon/config.py
Normal file
34
tools/atlas-daemon/atlas_daemon/config.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
discord_token: str
|
||||||
|
anthropic_key: str
|
||||||
|
data_dir: Path = field(default_factory=lambda: Path("./atlas-daemon-data"))
|
||||||
|
encryption_key: str = ""
|
||||||
|
sync_interval: int = 300
|
||||||
|
max_games_per_server: int = 5
|
||||||
|
atlas_binary: str = "atlas"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls, **overrides: object) -> Config:
|
||||||
|
def _get(key: str, default: str = "") -> str:
|
||||||
|
override = overrides.get(key.lower().removeprefix("atlas_daemon_"))
|
||||||
|
if override is not None:
|
||||||
|
return str(override)
|
||||||
|
return os.environ.get(f"ATLAS_DAEMON_{key}", default)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
discord_token=_get("DISCORD_TOKEN"),
|
||||||
|
anthropic_key=_get("ANTHROPIC_KEY"),
|
||||||
|
data_dir=Path(_get("DATA_DIR", "./atlas-daemon-data")),
|
||||||
|
encryption_key=_get("ENCRYPTION_KEY"),
|
||||||
|
sync_interval=int(_get("SYNC_INTERVAL", "300")),
|
||||||
|
max_games_per_server=int(_get("MAX_GAMES_PER_SERVER", "5")),
|
||||||
|
atlas_binary=_get("ATLAS_BINARY", "atlas"),
|
||||||
|
)
|
||||||
251
tools/atlas-daemon/atlas_daemon/daemon.py
Normal file
251
tools/atlas-daemon/atlas_daemon/daemon.py
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import anthropic
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from . import atlas_cli, discord_fmt, review, security
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .config import Config
|
||||||
|
from .db import Database, Game
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_sync_tasks: dict[int, asyncio.Task[None]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def init_game(
|
||||||
|
config: Config, place_id: int, server_id: str, opencloud_key: str
|
||||||
|
) -> Path:
|
||||||
|
game_dir = config.data_dir / server_id / str(place_id)
|
||||||
|
game_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
await atlas_cli.atlas_clone(config.atlas_binary, place_id, game_dir, opencloud_key)
|
||||||
|
await atlas_cli.git_init(game_dir)
|
||||||
|
await atlas_cli.git_add_all(game_dir)
|
||||||
|
await atlas_cli.git_commit(game_dir, "Initial sync")
|
||||||
|
|
||||||
|
return game_dir
|
||||||
|
|
||||||
|
|
||||||
|
def start_sync_loop(
|
||||||
|
game: Game,
|
||||||
|
config: Config,
|
||||||
|
db: Database,
|
||||||
|
bot: discord.Client,
|
||||||
|
claude: anthropic.AsyncAnthropic,
|
||||||
|
) -> None:
|
||||||
|
if game.id in _sync_tasks and not _sync_tasks[game.id].done():
|
||||||
|
return
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
_sync_loop(game, config, db, bot, claude), name=f"sync-{game.place_id}"
|
||||||
|
)
|
||||||
|
_sync_tasks[game.id] = task
|
||||||
|
|
||||||
|
|
||||||
|
def stop_sync_loop(game_id: int) -> None:
|
||||||
|
task = _sync_tasks.pop(game_id, None)
|
||||||
|
if task and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_all_for_server(db: Database, server_id: str) -> None:
|
||||||
|
for game in db.get_games_for_server(server_id):
|
||||||
|
stop_sync_loop(game.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_loop(
|
||||||
|
game: Game,
|
||||||
|
config: Config,
|
||||||
|
db: Database,
|
||||||
|
bot: discord.Client,
|
||||||
|
claude: anthropic.AsyncAnthropic,
|
||||||
|
) -> None:
|
||||||
|
await bot.wait_until_ready()
|
||||||
|
log.info("Starting sync loop for PlaceId %d (server %s)", game.place_id, game.discord_server_id)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
api_key = security.decrypt_api_key(game.api_key_encrypted)
|
||||||
|
working_dir = Path(game.working_dir)
|
||||||
|
|
||||||
|
await atlas_cli.atlas_syncback(
|
||||||
|
config.atlas_binary, working_dir, game.place_id, api_key
|
||||||
|
)
|
||||||
|
|
||||||
|
diff = await atlas_cli.git_diff(working_dir)
|
||||||
|
if diff:
|
||||||
|
changed_files = await atlas_cli.git_diff_name_only(working_dir)
|
||||||
|
script_files = [
|
||||||
|
f for f in changed_files
|
||||||
|
if any(f.endswith(ext) for ext in review.REVIEWABLE_EXTENSIONS)
|
||||||
|
]
|
||||||
|
|
||||||
|
auto_resolved_ids = _auto_resolve(db, game.id, changed_files)
|
||||||
|
for rid in auto_resolved_ids:
|
||||||
|
await _update_resolved_embed(bot, db, rid)
|
||||||
|
|
||||||
|
if script_files:
|
||||||
|
existing = db.get_unresolved_issues(game.id)
|
||||||
|
try:
|
||||||
|
issues = await review.review_diff(claude, diff, existing)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_error(db, bot, game, str(exc), "Claude API error")
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
new_issues = [
|
||||||
|
i for i in issues
|
||||||
|
if not db.final_dedup(game.id, i.file, i.title)
|
||||||
|
]
|
||||||
|
|
||||||
|
if new_issues:
|
||||||
|
await _post_issues(bot, db, game, new_issues, diff, changed_files)
|
||||||
|
|
||||||
|
await atlas_cli.git_add_all(working_dir)
|
||||||
|
await atlas_cli.git_commit(
|
||||||
|
working_dir,
|
||||||
|
f"Sync {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
|
||||||
|
)
|
||||||
|
db.update_last_sync(game.id)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
log.info("Sync loop cancelled for PlaceId %d", game.place_id)
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
error_str = str(exc)
|
||||||
|
error_type = "Syncback error"
|
||||||
|
if "HTTP 403" in error_str or "HTTP 401" in error_str:
|
||||||
|
error_type = "Download failure (auth)"
|
||||||
|
elif "Failed to download" in error_str:
|
||||||
|
error_type = "Download failure"
|
||||||
|
elif "atlas syncback failed" in error_str:
|
||||||
|
error_type = "Syncback crash"
|
||||||
|
elif "git" in error_str.lower():
|
||||||
|
error_type = "Git error"
|
||||||
|
|
||||||
|
should_report = db.update_last_error(game.id, error_str)
|
||||||
|
if should_report:
|
||||||
|
await _send_error(bot, game, error_str, error_type)
|
||||||
|
|
||||||
|
log.exception("Sync loop error for PlaceId %d", game.place_id)
|
||||||
|
|
||||||
|
await asyncio.sleep(config.sync_interval)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_resolve(db: Database, game_id: int, changed_files: list[str]) -> list[int]:
|
||||||
|
resolved_ids = []
|
||||||
|
for issue in db.get_unresolved_issues(game_id):
|
||||||
|
if issue.file_path in changed_files:
|
||||||
|
db.resolve_issue(issue.id, resolved_by="auto", reason="file modified")
|
||||||
|
resolved_ids.append(issue.id)
|
||||||
|
return resolved_ids
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_resolved_embed(bot: discord.Client, db: Database, issue_id: int) -> None:
|
||||||
|
from .db import DBIssue
|
||||||
|
|
||||||
|
row = db._conn.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
issue = DBIssue(*row)
|
||||||
|
if not issue.discord_message_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
for guild in bot.guilds:
|
||||||
|
for channel in guild.text_channels:
|
||||||
|
try:
|
||||||
|
msg = await channel.fetch_message(int(issue.discord_message_id))
|
||||||
|
if msg.embeds:
|
||||||
|
embed = msg.embeds[0]
|
||||||
|
embed.set_footer(text="Resolved -- code was modified")
|
||||||
|
embed.color = 0x95A5A6
|
||||||
|
await msg.edit(embed=embed, view=None)
|
||||||
|
return
|
||||||
|
except (discord.NotFound, discord.Forbidden):
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
log.debug("Could not update resolved embed for issue %d", issue_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _post_issues(
|
||||||
|
bot: discord.Client,
|
||||||
|
db: Database,
|
||||||
|
game: Game,
|
||||||
|
issues: list[review.Issue],
|
||||||
|
diff: str,
|
||||||
|
changed_files: list[str],
|
||||||
|
) -> None:
|
||||||
|
channel = bot.get_channel(int(game.channel_id))
|
||||||
|
if not channel or not isinstance(channel, discord.TextChannel):
|
||||||
|
log.warning("Channel %s not found for PlaceId %d", game.channel_id, game.place_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines_added, lines_removed = discord_fmt.count_diff_lines(diff)
|
||||||
|
script_files = [
|
||||||
|
f for f in changed_files
|
||||||
|
if any(f.endswith(ext) for ext in review.REVIEWABLE_EXTENSIONS)
|
||||||
|
]
|
||||||
|
|
||||||
|
auto_resolved_count = len([
|
||||||
|
i for i in db.get_unresolved_issues(game.id)
|
||||||
|
])
|
||||||
|
|
||||||
|
summary = discord_fmt.make_summary_embed(
|
||||||
|
game.place_id, issues, 0, script_files, lines_added, lines_removed
|
||||||
|
)
|
||||||
|
|
||||||
|
has_critical = any(i.severity == "Critical" for i in issues)
|
||||||
|
content = "@here" if has_critical else None
|
||||||
|
|
||||||
|
await channel.send(content=content, embed=summary)
|
||||||
|
|
||||||
|
for issue in issues:
|
||||||
|
embed = discord_fmt.make_issue_embed(issue)
|
||||||
|
issue_db_id = db.add_issue(
|
||||||
|
game_id=game.id,
|
||||||
|
discord_message_id=None,
|
||||||
|
file_path=issue.file,
|
||||||
|
line_start=issue.line_start,
|
||||||
|
line_end=issue.line_end,
|
||||||
|
severity=issue.severity,
|
||||||
|
title=issue.title,
|
||||||
|
explanation=issue.explanation,
|
||||||
|
suggestion=issue.suggestion,
|
||||||
|
)
|
||||||
|
|
||||||
|
view = discord_fmt.IssueButtons(issue_db_id)
|
||||||
|
msg = await channel.send(embed=embed, view=view)
|
||||||
|
db.update_issue_message_id(issue_db_id, str(msg.id))
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_error(
|
||||||
|
bot: discord.Client, game: Game, error: str, error_type: str
|
||||||
|
) -> None:
|
||||||
|
channel = bot.get_channel(int(game.channel_id))
|
||||||
|
if not channel or not isinstance(channel, discord.TextChannel):
|
||||||
|
return
|
||||||
|
|
||||||
|
embed = discord_fmt.make_error_embed(
|
||||||
|
game.place_id, error, error_type, game.last_sync_at
|
||||||
|
)
|
||||||
|
await channel.send(embed=embed)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_error(
|
||||||
|
db: Database,
|
||||||
|
bot: discord.Client,
|
||||||
|
game: Game,
|
||||||
|
error: str,
|
||||||
|
error_type: str,
|
||||||
|
) -> None:
|
||||||
|
should_report = db.update_last_error(game.id, error)
|
||||||
|
if should_report:
|
||||||
|
asyncio.create_task(_send_error(bot, game, error, error_type))
|
||||||
280
tools/atlas-daemon/atlas_daemon/db.py
Normal file
280
tools/atlas-daemon/atlas_daemon/db.py
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
GLOBAL_ADMIN_IDS = ["176643682689089545", "1010301288946339920"]
|
||||||
|
|
||||||
|
SCHEMA = """\
|
||||||
|
CREATE TABLE IF NOT EXISTS allowed_servers (
|
||||||
|
discord_server_id TEXT PRIMARY KEY,
|
||||||
|
server_name TEXT,
|
||||||
|
approved_by TEXT NOT NULL,
|
||||||
|
approved_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
discord_user_id TEXT PRIMARY KEY,
|
||||||
|
added_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS games (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
discord_server_id TEXT NOT NULL REFERENCES allowed_servers(discord_server_id) ON DELETE CASCADE,
|
||||||
|
place_id INTEGER NOT NULL,
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
api_key_encrypted BLOB NOT NULL,
|
||||||
|
added_by TEXT NOT NULL,
|
||||||
|
working_dir TEXT NOT NULL,
|
||||||
|
last_sync_at TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
last_error_at TEXT,
|
||||||
|
error_count INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(discord_server_id, place_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS issues (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,
|
||||||
|
discord_message_id TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
line_start INTEGER,
|
||||||
|
line_end INTEGER,
|
||||||
|
severity TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
explanation TEXT,
|
||||||
|
suggestion TEXT,
|
||||||
|
resolved INTEGER DEFAULT 0,
|
||||||
|
resolved_by TEXT,
|
||||||
|
resolved_reason TEXT,
|
||||||
|
resolved_at TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_issues_game_unresolved ON issues(game_id, resolved);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_games_server ON games(discord_server_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Game:
|
||||||
|
id: int
|
||||||
|
discord_server_id: str
|
||||||
|
place_id: int
|
||||||
|
channel_id: str
|
||||||
|
api_key_encrypted: bytes
|
||||||
|
added_by: str
|
||||||
|
working_dir: str
|
||||||
|
last_sync_at: str | None
|
||||||
|
last_error: str | None
|
||||||
|
last_error_at: str | None
|
||||||
|
error_count: int
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DBIssue:
|
||||||
|
id: int
|
||||||
|
game_id: int
|
||||||
|
discord_message_id: str | None
|
||||||
|
file_path: str
|
||||||
|
line_start: int | None
|
||||||
|
line_end: int | None
|
||||||
|
severity: str
|
||||||
|
title: str
|
||||||
|
explanation: str | None
|
||||||
|
suggestion: str | None
|
||||||
|
resolved: bool
|
||||||
|
resolved_by: str | None
|
||||||
|
resolved_reason: str | None
|
||||||
|
resolved_at: str | None
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, db_path: Path) -> None:
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
||||||
|
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self._conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
self._conn.executescript(SCHEMA)
|
||||||
|
self._seed_admins()
|
||||||
|
|
||||||
|
def _seed_admins(self) -> None:
|
||||||
|
for uid in GLOBAL_ADMIN_IDS:
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO admins (discord_user_id) VALUES (?)", (uid,)
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def is_admin(self, user_id: str) -> bool:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT 1 FROM admins WHERE discord_user_id = ?", (user_id,)
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
# -- Server whitelist --
|
||||||
|
|
||||||
|
def is_server_allowed(self, server_id: str) -> bool:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT 1 FROM allowed_servers WHERE discord_server_id = ?", (server_id,)
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
def approve_server(
|
||||||
|
self, server_id: str, server_name: str | None, approved_by: str
|
||||||
|
) -> None:
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO allowed_servers (discord_server_id, server_name, approved_by) VALUES (?, ?, ?)",
|
||||||
|
(server_id, server_name, approved_by),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def revoke_server(self, server_id: str) -> None:
|
||||||
|
self._conn.execute(
|
||||||
|
"DELETE FROM allowed_servers WHERE discord_server_id = ?", (server_id,)
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
# -- Games --
|
||||||
|
|
||||||
|
def add_game(
|
||||||
|
self,
|
||||||
|
server_id: str,
|
||||||
|
place_id: int,
|
||||||
|
channel_id: str,
|
||||||
|
api_key_encrypted: bytes,
|
||||||
|
added_by: str,
|
||||||
|
working_dir: str,
|
||||||
|
) -> int:
|
||||||
|
cur = self._conn.execute(
|
||||||
|
"INSERT INTO games (discord_server_id, place_id, channel_id, api_key_encrypted, added_by, working_dir) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(server_id, place_id, channel_id, api_key_encrypted, added_by, working_dir),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return cur.lastrowid # type: ignore[return-value]
|
||||||
|
|
||||||
|
def remove_game(self, server_id: str, place_id: int) -> bool:
|
||||||
|
cur = self._conn.execute(
|
||||||
|
"DELETE FROM games WHERE discord_server_id = ? AND place_id = ?",
|
||||||
|
(server_id, place_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
def get_game(self, server_id: str, place_id: int) -> Game | None:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT * FROM games WHERE discord_server_id = ? AND place_id = ?",
|
||||||
|
(server_id, place_id),
|
||||||
|
).fetchone()
|
||||||
|
return Game(*row) if row else None
|
||||||
|
|
||||||
|
def get_games_for_server(self, server_id: str) -> list[Game]:
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"SELECT * FROM games WHERE discord_server_id = ?", (server_id,)
|
||||||
|
).fetchall()
|
||||||
|
return [Game(*r) for r in rows]
|
||||||
|
|
||||||
|
def get_all_games(self) -> list[Game]:
|
||||||
|
rows = self._conn.execute("SELECT * FROM games").fetchall()
|
||||||
|
return [Game(*r) for r in rows]
|
||||||
|
|
||||||
|
def count_games_for_server(self, server_id: str) -> int:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM games WHERE discord_server_id = ?", (server_id,)
|
||||||
|
).fetchone()
|
||||||
|
return row[0] if row else 0
|
||||||
|
|
||||||
|
def update_last_sync(self, game_id: int) -> None:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE games SET last_sync_at = ?, last_error = NULL, last_error_at = NULL, error_count = 0 WHERE id = ?",
|
||||||
|
(now, game_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def update_last_error(self, game_id: int, error: str) -> bool:
|
||||||
|
"""Update error state. Returns True if this is a new error or throttle window expired."""
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT last_error, error_count FROM games WHERE id = ?", (game_id,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
if row and row[0] == error:
|
||||||
|
new_count = (row[1] or 0) + 1
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE games SET error_count = ? WHERE id = ?",
|
||||||
|
(new_count, game_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return new_count == 1 or new_count % 12 == 0
|
||||||
|
else:
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE games SET last_error = ?, last_error_at = ?, error_count = 1 WHERE id = ?",
|
||||||
|
(error, now, game_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# -- Issues --
|
||||||
|
|
||||||
|
def get_unresolved_issues(self, game_id: int) -> list[DBIssue]:
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"SELECT * FROM issues WHERE game_id = ? AND resolved = 0", (game_id,)
|
||||||
|
).fetchall()
|
||||||
|
return [DBIssue(*r) for r in rows]
|
||||||
|
|
||||||
|
def add_issue(
|
||||||
|
self,
|
||||||
|
game_id: int,
|
||||||
|
discord_message_id: str | None,
|
||||||
|
file_path: str,
|
||||||
|
line_start: int | None,
|
||||||
|
line_end: int | None,
|
||||||
|
severity: str,
|
||||||
|
title: str,
|
||||||
|
explanation: str | None,
|
||||||
|
suggestion: str | None,
|
||||||
|
) -> int:
|
||||||
|
cur = self._conn.execute(
|
||||||
|
"INSERT INTO issues (game_id, discord_message_id, file_path, line_start, line_end, "
|
||||||
|
"severity, title, explanation, suggestion) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(game_id, discord_message_id, file_path, line_start, line_end, severity, title, explanation, suggestion),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return cur.lastrowid # type: ignore[return-value]
|
||||||
|
|
||||||
|
def resolve_issue(
|
||||||
|
self, issue_id: int, resolved_by: str, reason: str = "manual"
|
||||||
|
) -> None:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE issues SET resolved = 1, resolved_by = ?, resolved_reason = ?, resolved_at = ? WHERE id = ?",
|
||||||
|
(resolved_by, reason, now, issue_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def update_issue_message_id(self, issue_id: int, message_id: str) -> None:
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE issues SET discord_message_id = ? WHERE id = ?",
|
||||||
|
(message_id, issue_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def get_issue_by_message_id(self, message_id: str) -> DBIssue | None:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT * FROM issues WHERE discord_message_id = ?", (message_id,)
|
||||||
|
).fetchone()
|
||||||
|
return DBIssue(*row) if row else None
|
||||||
|
|
||||||
|
def final_dedup(self, game_id: int, file_path: str, title: str) -> bool:
|
||||||
|
"""Returns True if this (file, title) pair already exists as unresolved."""
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT 1 FROM issues WHERE game_id = ? AND file_path = ? AND title = ? AND resolved = 0",
|
||||||
|
(game_id, file_path, title),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
158
tools/atlas-daemon/atlas_daemon/discord_fmt.py
Normal file
158
tools/atlas-daemon/atlas_daemon/discord_fmt.py
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .db import Game
|
||||||
|
from .review import Issue
|
||||||
|
|
||||||
|
SEVERITY_COLORS = {
|
||||||
|
"Low": 0x2ECC71,
|
||||||
|
"Medium": 0xF1C40F,
|
||||||
|
"High": 0xE67E22,
|
||||||
|
"Critical": 0xE74C3C,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_issue_embed(issue: Issue) -> discord.Embed:
|
||||||
|
color = SEVERITY_COLORS.get(issue.severity, 0x95A5A6)
|
||||||
|
title = f"[{issue.severity}] {issue.title}"
|
||||||
|
if len(title) > 256:
|
||||||
|
title = title[:253] + "..."
|
||||||
|
|
||||||
|
description_parts = [f"**File:** `{issue.file}`"]
|
||||||
|
if issue.line_start:
|
||||||
|
if issue.line_end and issue.line_end != issue.line_start:
|
||||||
|
description_parts.append(f"**Lines:** {issue.line_start}-{issue.line_end}")
|
||||||
|
else:
|
||||||
|
description_parts.append(f"**Line:** {issue.line_start}")
|
||||||
|
|
||||||
|
description_parts.append("")
|
||||||
|
description_parts.append(issue.explanation)
|
||||||
|
|
||||||
|
if issue.suggestion:
|
||||||
|
description_parts.append("")
|
||||||
|
suggestion_text = issue.suggestion
|
||||||
|
if len(suggestion_text) > 800:
|
||||||
|
suggestion_text = suggestion_text[:797] + "..."
|
||||||
|
description_parts.append(f"**Suggested fix:**\n```lua\n{suggestion_text}\n```")
|
||||||
|
|
||||||
|
description = "\n".join(description_parts)
|
||||||
|
if len(description) > 4096:
|
||||||
|
description = description[:4093] + "..."
|
||||||
|
|
||||||
|
return discord.Embed(title=title, description=description, color=color)
|
||||||
|
|
||||||
|
|
||||||
|
def make_summary_embed(
|
||||||
|
place_id: int,
|
||||||
|
new_issues: list[Issue],
|
||||||
|
auto_resolved_count: int,
|
||||||
|
changed_files: list[str],
|
||||||
|
lines_added: int,
|
||||||
|
lines_removed: int,
|
||||||
|
) -> discord.Embed:
|
||||||
|
severity_counts = {"Critical": 0, "High": 0, "Medium": 0, "Low": 0}
|
||||||
|
for issue in new_issues:
|
||||||
|
severity_counts[issue.severity] = severity_counts.get(issue.severity, 0) + 1
|
||||||
|
|
||||||
|
counts_str = " | ".join(
|
||||||
|
f"{count} {sev}" for sev, count in severity_counts.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
description_parts = [
|
||||||
|
f"**{len(new_issues)} new issue{'s' if len(new_issues) != 1 else ''} found**",
|
||||||
|
]
|
||||||
|
if auto_resolved_count:
|
||||||
|
description_parts[0] += f" | {auto_resolved_count} auto-resolved"
|
||||||
|
|
||||||
|
description_parts.append(f"\n{counts_str}")
|
||||||
|
description_parts.append(
|
||||||
|
f"\nReviewed {len(changed_files)} changed script{'s' if len(changed_files) != 1 else ''} "
|
||||||
|
f"({lines_added} lines added, {lines_removed} removed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return discord.Embed(
|
||||||
|
title=f"Sync Review -- PlaceId {place_id}",
|
||||||
|
description="\n".join(description_parts),
|
||||||
|
color=0x3498DB,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_error_embed(place_id: int, error: str, error_type: str, last_sync: str | None) -> discord.Embed:
|
||||||
|
description = error
|
||||||
|
if len(description) > 1500:
|
||||||
|
description = description[:1497] + "..."
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=f"Sync Error -- PlaceId {place_id}",
|
||||||
|
description=f"```\n{description}\n```",
|
||||||
|
color=0xE74C3C,
|
||||||
|
)
|
||||||
|
embed.add_field(name="Error Type", value=error_type, inline=True)
|
||||||
|
embed.add_field(name="Last Successful Sync", value=last_sync or "Never", inline=True)
|
||||||
|
embed.set_footer(text="Will retry next cycle")
|
||||||
|
return embed
|
||||||
|
|
||||||
|
|
||||||
|
class IssueButtons(discord.ui.View):
|
||||||
|
def __init__(self, issue_db_id: int) -> None:
|
||||||
|
super().__init__(timeout=None)
|
||||||
|
self.issue_db_id = issue_db_id
|
||||||
|
|
||||||
|
@discord.ui.button(label="Mark Fixed", style=discord.ButtonStyle.success, custom_id="mark_fixed")
|
||||||
|
async def mark_fixed(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
||||||
|
from . import _bot_ref
|
||||||
|
|
||||||
|
if _bot_ref.db is None:
|
||||||
|
await interaction.response.send_message("Database not available.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
_bot_ref.db.resolve_issue(self.issue_db_id, resolved_by=str(interaction.user.id))
|
||||||
|
|
||||||
|
embed = interaction.message.embeds[0] if interaction.message and interaction.message.embeds else None
|
||||||
|
if embed:
|
||||||
|
embed.set_footer(text=f"Resolved by {interaction.user.display_name}")
|
||||||
|
embed.color = 0x95A5A6
|
||||||
|
if interaction.message:
|
||||||
|
await interaction.message.edit(embed=embed, view=None)
|
||||||
|
await interaction.response.send_message("Marked as fixed.", ephemeral=True)
|
||||||
|
|
||||||
|
@discord.ui.button(label="Copy Context", style=discord.ButtonStyle.secondary, custom_id="copy_context")
|
||||||
|
async def copy_context(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
||||||
|
from . import _bot_ref
|
||||||
|
|
||||||
|
if _bot_ref.db is None:
|
||||||
|
await interaction.response.send_message("Database not available.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
issue = _bot_ref.db.get_issue_by_message_id(str(interaction.message.id)) if interaction.message else None
|
||||||
|
if not issue:
|
||||||
|
await interaction.response.send_message("Issue not found.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
context = (
|
||||||
|
f"File: {issue.file_path}\n"
|
||||||
|
f"Lines: {issue.line_start}-{issue.line_end}\n"
|
||||||
|
f"Severity: {issue.severity}\n"
|
||||||
|
f"Title: {issue.title}\n\n"
|
||||||
|
f"Explanation: {issue.explanation}\n\n"
|
||||||
|
f"Suggestion: {issue.suggestion}"
|
||||||
|
)
|
||||||
|
if len(context) > 1900:
|
||||||
|
context = context[:1897] + "..."
|
||||||
|
|
||||||
|
await interaction.response.send_message(f"```\n{context}\n```", ephemeral=True)
|
||||||
|
|
||||||
|
|
||||||
|
def count_diff_lines(diff: str) -> tuple[int, int]:
|
||||||
|
added = 0
|
||||||
|
removed = 0
|
||||||
|
for line in diff.splitlines():
|
||||||
|
if line.startswith("+") and not line.startswith("+++"):
|
||||||
|
added += 1
|
||||||
|
elif line.startswith("-") and not line.startswith("---"):
|
||||||
|
removed += 1
|
||||||
|
return added, removed
|
||||||
185
tools/atlas-daemon/atlas_daemon/review.py
Normal file
185
tools/atlas-daemon/atlas_daemon/review.py
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from importlib import resources
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import anthropic
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .db import DBIssue
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
REVIEWABLE_EXTENSIONS = (".luau", ".lua")
|
||||||
|
TOKEN_ESTIMATE_DIVISOR = 4
|
||||||
|
MAX_SINGLE_PASS_TOKENS = 150_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Issue:
|
||||||
|
file: str
|
||||||
|
line_start: int
|
||||||
|
line_end: int
|
||||||
|
severity: str
|
||||||
|
title: str
|
||||||
|
explanation: str
|
||||||
|
suggestion: str
|
||||||
|
|
||||||
|
|
||||||
|
def _load_system_prompt() -> str:
|
||||||
|
return resources.files("atlas_daemon").joinpath("review_prompt.txt").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def split_diff_by_file(raw_diff: str) -> list[str]:
|
||||||
|
chunks: list[str] = []
|
||||||
|
current: list[str] = []
|
||||||
|
|
||||||
|
for line in raw_diff.splitlines(keepends=True):
|
||||||
|
if line.startswith("diff --git "):
|
||||||
|
if current:
|
||||||
|
chunks.append("".join(current))
|
||||||
|
current = [line]
|
||||||
|
else:
|
||||||
|
current.append(line)
|
||||||
|
|
||||||
|
if current:
|
||||||
|
chunks.append("".join(current))
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def extract_file_path(file_diff: str) -> str:
|
||||||
|
for line in file_diff.splitlines():
|
||||||
|
if line.startswith("+++ b/"):
|
||||||
|
return line[6:].strip()
|
||||||
|
if line.startswith("+++ "):
|
||||||
|
return line[4:].strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def filter_script_changes(raw_diff: str) -> str:
|
||||||
|
filtered = []
|
||||||
|
for chunk in split_diff_by_file(raw_diff):
|
||||||
|
path = extract_file_path(chunk)
|
||||||
|
if any(path.endswith(ext) for ext in REVIEWABLE_EXTENSIONS):
|
||||||
|
filtered.append(chunk)
|
||||||
|
return "\n".join(filtered)
|
||||||
|
|
||||||
|
|
||||||
|
def format_existing_issues(existing: list[DBIssue]) -> str:
|
||||||
|
if not existing:
|
||||||
|
return ""
|
||||||
|
lines = ["Already-reported unresolved issues (do NOT re-report these):"]
|
||||||
|
for e in existing:
|
||||||
|
lines.append(f"- [{e.severity}] {e.file_path}:{e.line_start} -- {e.title}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_issues(text: str) -> list[Issue]:
|
||||||
|
text = text.strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = re.sub(r"^```\w*\n?", "", text)
|
||||||
|
text = re.sub(r"\n?```$", "", text)
|
||||||
|
text = text.strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
match = re.search(r"\[.*\]", text, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
data = json.loads(match.group())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
log.warning("Could not parse review response as JSON")
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
log.warning("No JSON array found in review response")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
issues: list[Issue] = []
|
||||||
|
for item in data:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
issues.append(
|
||||||
|
Issue(
|
||||||
|
file=str(item.get("file", "")),
|
||||||
|
line_start=int(item.get("line_start", 0)),
|
||||||
|
line_end=int(item.get("line_end", 0)),
|
||||||
|
severity=str(item.get("severity", "Medium")),
|
||||||
|
title=str(item.get("title", "")),
|
||||||
|
explanation=str(item.get("explanation", "")),
|
||||||
|
suggestion=str(item.get("suggestion", "")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
async def review_diff(
|
||||||
|
client: anthropic.AsyncAnthropic,
|
||||||
|
diff: str,
|
||||||
|
existing_issues: list[DBIssue],
|
||||||
|
) -> list[Issue]:
|
||||||
|
script_diff = filter_script_changes(diff)
|
||||||
|
if not script_diff:
|
||||||
|
return []
|
||||||
|
|
||||||
|
existing_context = format_existing_issues(existing_issues)
|
||||||
|
|
||||||
|
token_estimate = len(script_diff) // TOKEN_ESTIMATE_DIVISOR
|
||||||
|
if token_estimate > MAX_SINGLE_PASS_TOKENS:
|
||||||
|
return await _review_chunked(client, script_diff, existing_context)
|
||||||
|
|
||||||
|
user_content = f"{existing_context}\n\nReview this diff:\n\n{script_diff}" if existing_context else f"Review this diff:\n\n{script_diff}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.messages.create(
|
||||||
|
model="claude-sonnet-4-20250514",
|
||||||
|
max_tokens=4096,
|
||||||
|
system=_load_system_prompt(),
|
||||||
|
messages=[{"role": "user", "content": user_content}],
|
||||||
|
)
|
||||||
|
return parse_issues(response.content[0].text)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Claude API call failed")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _review_chunked(
|
||||||
|
client: anthropic.AsyncAnthropic,
|
||||||
|
diff: str,
|
||||||
|
existing_context: str,
|
||||||
|
) -> list[Issue]:
|
||||||
|
file_diffs = split_diff_by_file(diff)
|
||||||
|
file_diffs = [
|
||||||
|
fd for fd in file_diffs
|
||||||
|
if any(extract_file_path(fd).endswith(ext) for ext in REVIEWABLE_EXTENSIONS)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def review_one(file_diff: str) -> list[Issue]:
|
||||||
|
user_content = f"{existing_context}\n\nReview this diff:\n\n{file_diff}" if existing_context else f"Review this diff:\n\n{file_diff}"
|
||||||
|
try:
|
||||||
|
response = await client.messages.create(
|
||||||
|
model="claude-sonnet-4-20250514",
|
||||||
|
max_tokens=4096,
|
||||||
|
system=_load_system_prompt(),
|
||||||
|
messages=[{"role": "user", "content": user_content}],
|
||||||
|
)
|
||||||
|
return parse_issues(response.content[0].text)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Claude API call failed for chunked review")
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = await asyncio.gather(*[review_one(fd) for fd in file_diffs])
|
||||||
|
return [issue for file_issues in results for issue in file_issues]
|
||||||
40
tools/atlas-daemon/atlas_daemon/review_prompt.txt
Normal file
40
tools/atlas-daemon/atlas_daemon/review_prompt.txt
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
You are a code reviewer specializing in Roblox Luau game development. You review unified diffs of Luau script files (.luau, .lua) and identify bugs, security issues, and anti-patterns.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
1. Analyze ONLY added/changed lines (lines starting with +). Do not flag removed code.
|
||||||
|
2. Focus on these categories: logic bugs, security vulnerabilities, performance problems, Roblox/Luau anti-patterns, race conditions from yielding.
|
||||||
|
3. Output ONLY a JSON array. No markdown, no commentary outside the JSON.
|
||||||
|
4. Each issue must have: file, line_start, line_end, severity, title, explanation, suggestion.
|
||||||
|
5. Severity levels:
|
||||||
|
- Critical: Will crash, corrupt data, or create security holes (infinite loop, data loss, remote code execution via loadstring/require tricks)
|
||||||
|
- High: Likely to cause incorrect behavior (off-by-one, wrong event connection, race condition after yield, accessing nil values)
|
||||||
|
- Medium: Could cause issues under certain conditions (missing nil check, unbounded table growth, memory leak from disconnected events)
|
||||||
|
- Low: Minor improvement (deprecated API, suboptimal pattern)
|
||||||
|
6. Luau-specific checks:
|
||||||
|
- Arrays are 1-indexed. Flag any for loop starting at 0: `for i = 0, #arr do`
|
||||||
|
- `wait()` is deprecated. Should be `task.wait()`
|
||||||
|
- `spawn()` is deprecated. Should be `task.spawn()`
|
||||||
|
- `delay()` is deprecated. Should be `task.delay()`
|
||||||
|
- `getfenv`/`setfenv` are deprecated and break optimizations
|
||||||
|
- `Instance.new("ClassName", parent)` - second arg is deprecated, use `.Parent = parent` after
|
||||||
|
- Yielding inside RenderStepped/Heartbeat/Stepped callbacks causes frame drops
|
||||||
|
- Missing `task.cancel()` on spawned threads during cleanup
|
||||||
|
- Unbounded `table.insert` without cleanup (memory leak)
|
||||||
|
- Using `pairs()` or `ipairs()` when direct `for k, v in tbl do` works
|
||||||
|
- `string.format` with user input without sanitization
|
||||||
|
7. Do NOT flag: style preferences, formatting, naming conventions, missing comments, TODO items, whitespace changes.
|
||||||
|
8. If no issues are found, return an empty array: []
|
||||||
|
9. Skip any issues listed in the "Already-reported unresolved issues" section - these are known and tracked.
|
||||||
|
|
||||||
|
OUTPUT FORMAT (strict JSON, no markdown fences):
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"file": "src/server/PlayerManager.server.luau",
|
||||||
|
"line_start": 47,
|
||||||
|
"line_end": 52,
|
||||||
|
"severity": "Critical",
|
||||||
|
"title": "0-indexed loop over 1-indexed array",
|
||||||
|
"explanation": "Luau arrays are 1-indexed. This loop starts at 0, which returns nil on the first iteration and skips the last element entirely.",
|
||||||
|
"suggestion": "for i = 1, #players do"
|
||||||
|
}
|
||||||
|
]
|
||||||
32
tools/atlas-daemon/atlas_daemon/security.py
Normal file
32
tools/atlas-daemon/atlas_daemon/security.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
_fernet: Fernet | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_encryption(key: str) -> None:
|
||||||
|
global _fernet
|
||||||
|
if not key:
|
||||||
|
_fernet = Fernet(Fernet.generate_key())
|
||||||
|
return
|
||||||
|
if len(key) == 44 and key.endswith("="):
|
||||||
|
_fernet = Fernet(key.encode())
|
||||||
|
else:
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
derived = hashlib.sha256(key.encode()).digest()
|
||||||
|
_fernet = Fernet(base64.urlsafe_b64encode(derived))
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_api_key(plaintext: str) -> bytes:
|
||||||
|
if _fernet is None:
|
||||||
|
raise RuntimeError("Encryption not initialized. Call init_encryption() first.")
|
||||||
|
return _fernet.encrypt(plaintext.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_api_key(ciphertext: bytes) -> str:
|
||||||
|
if _fernet is None:
|
||||||
|
raise RuntimeError("Encryption not initialized. Call init_encryption() first.")
|
||||||
|
return _fernet.decrypt(ciphertext).decode()
|
||||||
14
tools/atlas-daemon/pyproject.toml
Normal file
14
tools/atlas-daemon/pyproject.toml
Normal file
|
|
@ -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"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue