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