fix: protect server config from truncation and recover from Discord guilds

- Save servers_config.json atomically via temp file + fsync + rename
- Keep .bak backup and auto-restore when main config is empty/corrupt
- Add /servers/recover endpoint for manual recovery
- Auto-recover basic server configs on startup when config is empty but bot is in guilds
This commit is contained in:
2026-06-11 20:37:04 +03:00
parent 486acb5c14
commit cfd5eb16f7
3 changed files with 222 additions and 20 deletions

View File

@@ -79,23 +79,60 @@ class ServerManager:
self.load_config()
def load_config(self):
"""Load server configurations from file"""
if os.path.exists(self.config_file):
try:
with open(self.config_file, "r", encoding="utf-8") as f:
data = json.load(f)
for guild_id_str, server_data in data.items():
guild_id = int(guild_id_str)
self.servers[guild_id] = ServerConfig.from_dict(server_data)
logger.info(f"Loaded config for server: {server_data['guild_name']} (ID: {guild_id})")
# After loading, check if we need to repair the config
self.repair_config()
except Exception as e:
logger.error(f"Failed to load server config: {e}")
logger.info("Starting with zero servers — add servers via the API or dashboard")
else:
logger.info("No servers_config.json found — starting with zero servers")
"""Load server configurations from file.
If the main file is missing, empty, or corrupt, falls back to the
.bak backup file automatically.
"""
loaded = self._try_load_file(self.config_file)
if not loaded:
# Try backup file
bak_file = self.config_file + ".bak"
if os.path.exists(bak_file):
logger.warning(f"Main config is empty/corrupt, trying backup: {bak_file}")
loaded = self._try_load_file(bak_file)
if loaded:
# Restore main config from backup
logger.info("Successfully restored server config from backup")
self.save_config()
if not loaded:
logger.info("No valid servers_config.json found — starting with zero servers")
# After loading, check if we need to repair the config
self.repair_config()
def _try_load_file(self, filepath: str) -> bool:
"""Try to load server config from a file. Returns True if any servers loaded."""
if not os.path.exists(filepath):
return False
# Check for empty file
if os.path.getsize(filepath) == 0:
logger.warning(f"Config file is empty (0 bytes): {filepath}")
return False
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
if not data or not isinstance(data, dict):
logger.warning(f"Config file has invalid structure: {filepath}")
return False
for guild_id_str, server_data in data.items():
guild_id = int(guild_id_str)
self.servers[guild_id] = ServerConfig.from_dict(server_data)
logger.info(f"Loaded config for server: {server_data.get('guild_name', 'Unknown')} (ID: {guild_id})")
return len(self.servers) > 0
except json.JSONDecodeError as e:
logger.error(f"Failed to parse server config from {filepath}: {e}")
return False
except Exception as e:
logger.error(f"Failed to load server config from {filepath}: {e}")
return False
def repair_config(self):
"""Repair corrupted configuration data and save it back"""
@@ -122,7 +159,11 @@ class ServerManager:
logger.error(f"Failed to repair config: {e}")
def save_config(self):
"""Save server configurations to file"""
"""Save server configurations to file (atomic write with backup).
Uses write-to-temp-then-rename to prevent file corruption if the
filesystem runs out of space mid-write. Also keeps a .bak backup.
"""
try:
os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
config_data = {}
@@ -134,10 +175,42 @@ class ServerManager:
server_dict['enabled_features'] = list(server_dict['enabled_features'])
config_data[str(guild_id)] = server_dict
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump(config_data, f, indent=2)
serialized = json.dumps(config_data, indent=2)
# Step 1: Write to a temporary file first
tmp_file = self.config_file + ".tmp"
with open(tmp_file, "w", encoding="utf-8") as f:
f.write(serialized)
f.flush()
os.fsync(f.fileno()) # Ensure data is written to disk
# Step 2: Keep a .bak copy of the current valid config (if any)
bak_file = self.config_file + ".bak"
if os.path.exists(self.config_file) and os.path.getsize(self.config_file) > 0:
try:
os.replace(self.config_file, bak_file)
except OSError as e:
logger.warning(f"Could not create backup of server config: {e}")
# Step 3: Atomically rename temp file to the real config file
os.replace(tmp_file, self.config_file)
# Step 4: Write a second backup copy (paranoid double-backup)
try:
with open(bak_file, "w", encoding="utf-8") as f:
f.write(serialized)
except OSError:
pass # Backup is best-effort
except Exception as e:
logger.error(f"Failed to save server config: {e}")
# Clean up temp file if something went wrong
tmp_file = self.config_file + ".tmp"
if os.path.exists(tmp_file):
try:
os.remove(tmp_file)
except OSError:
pass
def add_server(self, guild_id: int, guild_name: str, autonomous_channel_id: int,
autonomous_channel_name: str, bedtime_channel_ids: List[int] = None,