From 03dd449551887edd3854b623b36238b5bd7dab88 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 17 Mar 2026 13:34:43 -0500 Subject: [PATCH] fix: split read-only data volume to allow state file writes (#85) The data/ volume was mounted :ro to protect Google Sheets credentials, but this also prevented all state trackers from persisting JSON files (scorecards, voice channels, trade channels, soak data), causing silent save failures and stale data accumulating across restarts. - Mount only the credentials file as :ro (file-level mount) - Add a separate :rw storage/ volume for runtime state files - Move all tracker default paths from data/ to storage/ - Add STATE_HOST_PATH env var (defaults to ./storage) - Update SHEETS_CREDENTIALS_HOST_PATH semantics: now a file path (e.g. ./data/major-domo-service-creds.json) instead of a directory - Add storage/ to .gitignore Closes #85 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + commands/gameplay/scorecard_tracker.py | 2 +- commands/soak/tracker.py | 41 +++-- .../transactions/trade_channel_tracker.py | 29 ++-- commands/voice/cleanup_service.py | 153 +++++++++++++----- commands/voice/tracker.py | 47 ++++-- docker-compose.yml | 7 +- 7 files changed, 189 insertions(+), 91 deletions(-) diff --git a/.gitignore b/.gitignore index 9500d65..28d68a6 100644 --- a/.gitignore +++ b/.gitignore @@ -218,5 +218,6 @@ __marimo__/ # Project-specific data/ +storage/ production_logs/ *.json diff --git a/commands/gameplay/scorecard_tracker.py b/commands/gameplay/scorecard_tracker.py index 8b2a674..b5fd6db 100644 --- a/commands/gameplay/scorecard_tracker.py +++ b/commands/gameplay/scorecard_tracker.py @@ -24,7 +24,7 @@ class ScorecardTracker: - Timestamp tracking for monitoring """ - def __init__(self, data_file: str = "data/scorecards.json"): + def __init__(self, data_file: str = "storage/scorecards.json"): """ Initialize the scorecard tracker. diff --git a/commands/soak/tracker.py b/commands/soak/tracker.py index f084a6a..4708a5e 100644 --- a/commands/soak/tracker.py +++ b/commands/soak/tracker.py @@ -3,13 +3,14 @@ Soak Tracker Provides persistent tracking of "soak" mentions using JSON file storage. """ + import json import logging from datetime import datetime, timedelta, UTC from pathlib import Path from typing import Dict, List, Optional, Any -logger = logging.getLogger(f'{__name__}.SoakTracker') +logger = logging.getLogger(f"{__name__}.SoakTracker") class SoakTracker: @@ -22,7 +23,7 @@ class SoakTracker: - Time-based calculations for disappointment tiers """ - def __init__(self, data_file: str = "data/soak_data.json"): + def __init__(self, data_file: str = "storage/soak_data.json"): """ Initialize the soak tracker. @@ -38,28 +39,22 @@ class SoakTracker: """Load soak data from JSON file.""" try: if self.data_file.exists(): - with open(self.data_file, 'r') as f: + with open(self.data_file, "r") as f: self._data = json.load(f) - logger.debug(f"Loaded soak data: {self._data.get('total_count', 0)} total soaks") + logger.debug( + f"Loaded soak data: {self._data.get('total_count', 0)} total soaks" + ) else: - self._data = { - "last_soak": None, - "total_count": 0, - "history": [] - } + self._data = {"last_soak": None, "total_count": 0, "history": []} logger.info("No existing soak data found, starting fresh") except Exception as e: logger.error(f"Failed to load soak data: {e}") - self._data = { - "last_soak": None, - "total_count": 0, - "history": [] - } + self._data = {"last_soak": None, "total_count": 0, "history": []} def save_data(self) -> None: """Save soak data to JSON file.""" try: - with open(self.data_file, 'w') as f: + with open(self.data_file, "w") as f: json.dump(self._data, f, indent=2, default=str) logger.debug("Soak data saved successfully") except Exception as e: @@ -71,7 +66,7 @@ class SoakTracker: username: str, display_name: str, channel_id: int, - message_id: int + message_id: int, ) -> None: """ Record a new soak mention. @@ -89,7 +84,7 @@ class SoakTracker: "username": username, "display_name": display_name, "channel_id": str(channel_id), - "message_id": str(message_id) + "message_id": str(message_id), } # Update last_soak @@ -110,7 +105,9 @@ class SoakTracker: self.save_data() - logger.info(f"Recorded soak by {username} (ID: {user_id}) in channel {channel_id}") + logger.info( + f"Recorded soak by {username} (ID: {user_id}) in channel {channel_id}" + ) def get_last_soak(self) -> Optional[Dict[str, Any]]: """ @@ -135,10 +132,12 @@ class SoakTracker: try: # Parse ISO format timestamp last_timestamp_str = last_soak["timestamp"] - if last_timestamp_str.endswith('Z'): - last_timestamp_str = last_timestamp_str[:-1] + '+00:00' + if last_timestamp_str.endswith("Z"): + last_timestamp_str = last_timestamp_str[:-1] + "+00:00" - last_timestamp = datetime.fromisoformat(last_timestamp_str.replace('Z', '+00:00')) + last_timestamp = datetime.fromisoformat( + last_timestamp_str.replace("Z", "+00:00") + ) # Ensure both times are timezone-aware if last_timestamp.tzinfo is None: diff --git a/commands/transactions/trade_channel_tracker.py b/commands/transactions/trade_channel_tracker.py index f3d34c3..1399649 100644 --- a/commands/transactions/trade_channel_tracker.py +++ b/commands/transactions/trade_channel_tracker.py @@ -3,6 +3,7 @@ Trade Channel Tracker Provides persistent tracking of bot-created trade discussion channels using JSON file storage. """ + import json from datetime import datetime, UTC from pathlib import Path @@ -12,7 +13,7 @@ import discord from utils.logging import get_contextual_logger -logger = get_contextual_logger(f'{__name__}.TradeChannelTracker') +logger = get_contextual_logger(f"{__name__}.TradeChannelTracker") class TradeChannelTracker: @@ -26,7 +27,7 @@ class TradeChannelTracker: - Automatic stale entry removal """ - def __init__(self, data_file: str = "data/trade_channels.json"): + def __init__(self, data_file: str = "storage/trade_channels.json"): """ Initialize the trade channel tracker. @@ -42,9 +43,11 @@ class TradeChannelTracker: """Load channel data from JSON file.""" try: if self.data_file.exists(): - with open(self.data_file, 'r') as f: + with open(self.data_file, "r") as f: self._data = json.load(f) - logger.debug(f"Loaded {len(self._data.get('trade_channels', {}))} tracked trade channels") + logger.debug( + f"Loaded {len(self._data.get('trade_channels', {}))} tracked trade channels" + ) else: self._data = {"trade_channels": {}} logger.info("No existing trade channel data found, starting fresh") @@ -55,7 +58,7 @@ class TradeChannelTracker: def save_data(self) -> None: """Save channel data to JSON file.""" try: - with open(self.data_file, 'w') as f: + with open(self.data_file, "w") as f: json.dump(self._data, f, indent=2, default=str) logger.debug("Trade channel data saved successfully") except Exception as e: @@ -67,7 +70,7 @@ class TradeChannelTracker: trade_id: str, team1_abbrev: str, team2_abbrev: str, - creator_id: int + creator_id: int, ) -> None: """ Add a new trade channel to tracking. @@ -87,10 +90,12 @@ class TradeChannelTracker: "team1_abbrev": team1_abbrev, "team2_abbrev": team2_abbrev, "created_at": datetime.now(UTC).isoformat(), - "creator_id": str(creator_id) + "creator_id": str(creator_id), } self.save_data() - logger.info(f"Added trade channel to tracking: {channel.name} (ID: {channel.id}, Trade: {trade_id})") + logger.info( + f"Added trade channel to tracking: {channel.name} (ID: {channel.id}, Trade: {trade_id})" + ) def remove_channel(self, channel_id: int) -> None: """ @@ -108,7 +113,9 @@ class TradeChannelTracker: channel_name = channel_data["name"] del channels[channel_key] self.save_data() - logger.info(f"Removed trade channel from tracking: {channel_name} (ID: {channel_id}, Trade: {trade_id})") + logger.info( + f"Removed trade channel from tracking: {channel_name} (ID: {channel_id}, Trade: {trade_id})" + ) def get_channel_by_trade_id(self, trade_id: str) -> Optional[Dict[str, Any]]: """ @@ -175,7 +182,9 @@ class TradeChannelTracker: channel_name = channels[channel_id_str].get("name", "unknown") trade_id = channels[channel_id_str].get("trade_id", "unknown") del channels[channel_id_str] - logger.info(f"Removed stale tracking entry: {channel_name} (ID: {channel_id_str}, Trade: {trade_id})") + logger.info( + f"Removed stale tracking entry: {channel_name} (ID: {channel_id_str}, Trade: {trade_id})" + ) if stale_entries: self.save_data() diff --git a/commands/voice/cleanup_service.py b/commands/voice/cleanup_service.py index ab8792c..c7343d9 100644 --- a/commands/voice/cleanup_service.py +++ b/commands/voice/cleanup_service.py @@ -3,6 +3,7 @@ Voice Channel Cleanup Service Provides automatic cleanup of empty voice channels with restart resilience. """ + import logging import discord @@ -12,7 +13,7 @@ from .tracker import VoiceChannelTracker from commands.gameplay.scorecard_tracker import ScorecardTracker from utils.logging import get_contextual_logger -logger = logging.getLogger(f'{__name__}.VoiceChannelCleanupService') +logger = logging.getLogger(f"{__name__}.VoiceChannelCleanupService") class VoiceChannelCleanupService: @@ -27,7 +28,9 @@ class VoiceChannelCleanupService: - Automatic scorecard unpublishing when voice channel is cleaned up """ - def __init__(self, bot: commands.Bot, data_file: str = "data/voice_channels.json"): + def __init__( + self, bot: commands.Bot, data_file: str = "storage/voice_channels.json" + ): """ Initialize the cleanup service. @@ -36,10 +39,10 @@ class VoiceChannelCleanupService: data_file: Path to the JSON data file for persistence """ self.bot = bot - self.logger = get_contextual_logger(f'{__name__}.VoiceChannelCleanupService') + self.logger = get_contextual_logger(f"{__name__}.VoiceChannelCleanupService") self.tracker = VoiceChannelTracker(data_file) self.scorecard_tracker = ScorecardTracker() - self.empty_threshold = 5 # Delete after 5 minutes empty + self.empty_threshold = 5 # Delete after 5 minutes empty # Start the cleanup task - @before_loop will wait for bot readiness self.cleanup_loop.start() @@ -90,13 +93,17 @@ class VoiceChannelCleanupService: guild = bot.get_guild(guild_id) if not guild: - self.logger.warning(f"Guild {guild_id} not found, removing channel {channel_data['name']}") + self.logger.warning( + f"Guild {guild_id} not found, removing channel {channel_data['name']}" + ) channels_to_remove.append(channel_id) continue channel = guild.get_channel(channel_id) if not channel: - self.logger.warning(f"Channel {channel_data['name']} (ID: {channel_id}) no longer exists") + self.logger.warning( + f"Channel {channel_data['name']} (ID: {channel_id}) no longer exists" + ) channels_to_remove.append(channel_id) continue @@ -121,18 +128,26 @@ class VoiceChannelCleanupService: if channel_data and channel_data.get("text_channel_id"): try: text_channel_id_int = int(channel_data["text_channel_id"]) - was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int) + was_unpublished = self.scorecard_tracker.unpublish_scorecard( + text_channel_id_int + ) if was_unpublished: - self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel)") + self.logger.info( + f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel)" + ) except (ValueError, TypeError) as e: - self.logger.warning(f"Invalid text_channel_id in stale voice channel data: {e}") + self.logger.warning( + f"Invalid text_channel_id in stale voice channel data: {e}" + ) # Also clean up any additional stale entries stale_removed = self.tracker.cleanup_stale_entries(valid_channel_ids) total_removed = len(channels_to_remove) + stale_removed if total_removed > 0: - self.logger.info(f"Cleaned up {total_removed} stale channel tracking entries") + self.logger.info( + f"Cleaned up {total_removed} stale channel tracking entries" + ) self.logger.info(f"Verified {len(valid_channel_ids)} valid tracked channels") @@ -149,10 +164,14 @@ class VoiceChannelCleanupService: await self.update_all_channel_statuses(bot) # Get channels ready for cleanup - channels_for_cleanup = self.tracker.get_channels_for_cleanup(self.empty_threshold) + channels_for_cleanup = self.tracker.get_channels_for_cleanup( + self.empty_threshold + ) if channels_for_cleanup: - self.logger.info(f"Found {len(channels_for_cleanup)} channels ready for cleanup") + self.logger.info( + f"Found {len(channels_for_cleanup)} channels ready for cleanup" + ) # Delete empty channels for channel_data in channels_for_cleanup: @@ -182,12 +201,16 @@ class VoiceChannelCleanupService: guild = bot.get_guild(guild_id) if not guild: - self.logger.debug(f"Guild {guild_id} not found for channel {channel_data['name']}") + self.logger.debug( + f"Guild {guild_id} not found for channel {channel_data['name']}" + ) return channel = guild.get_channel(channel_id) if not channel: - self.logger.debug(f"Channel {channel_data['name']} no longer exists, removing from tracking") + self.logger.debug( + f"Channel {channel_data['name']} no longer exists, removing from tracking" + ) self.tracker.remove_channel(channel_id) # Unpublish associated scorecard if it exists @@ -195,17 +218,25 @@ class VoiceChannelCleanupService: if text_channel_id: try: text_channel_id_int = int(text_channel_id) - was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int) + was_unpublished = self.scorecard_tracker.unpublish_scorecard( + text_channel_id_int + ) if was_unpublished: - self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (manually deleted voice channel)") + self.logger.info( + f"📋 Unpublished scorecard from text channel {text_channel_id_int} (manually deleted voice channel)" + ) except (ValueError, TypeError) as e: - self.logger.warning(f"Invalid text_channel_id in manually deleted voice channel data: {e}") + self.logger.warning( + f"Invalid text_channel_id in manually deleted voice channel data: {e}" + ) return # Ensure it's a voice channel before checking members if not isinstance(channel, discord.VoiceChannel): - self.logger.warning(f"Channel {channel_data['name']} is not a voice channel, removing from tracking") + self.logger.warning( + f"Channel {channel_data['name']} is not a voice channel, removing from tracking" + ) self.tracker.remove_channel(channel_id) # Unpublish associated scorecard if it exists @@ -213,11 +244,17 @@ class VoiceChannelCleanupService: if text_channel_id: try: text_channel_id_int = int(text_channel_id) - was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int) + was_unpublished = self.scorecard_tracker.unpublish_scorecard( + text_channel_id_int + ) if was_unpublished: - self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (wrong channel type)") + self.logger.info( + f"📋 Unpublished scorecard from text channel {text_channel_id_int} (wrong channel type)" + ) except (ValueError, TypeError) as e: - self.logger.warning(f"Invalid text_channel_id in wrong channel type data: {e}") + self.logger.warning( + f"Invalid text_channel_id in wrong channel type data: {e}" + ) return @@ -225,11 +262,15 @@ class VoiceChannelCleanupService: is_empty = len(channel.members) == 0 self.tracker.update_channel_status(channel_id, is_empty) - self.logger.debug(f"Channel {channel_data['name']}: {'empty' if is_empty else 'occupied'} " - f"({len(channel.members)} members)") + self.logger.debug( + f"Channel {channel_data['name']}: {'empty' if is_empty else 'occupied'} " + f"({len(channel.members)} members)" + ) except Exception as e: - self.logger.error(f"Error checking channel status for {channel_data.get('name', 'unknown')}: {e}") + self.logger.error( + f"Error checking channel status for {channel_data.get('name', 'unknown')}: {e}" + ) async def cleanup_channel(self, bot: commands.Bot, channel_data: dict) -> None: """ @@ -246,25 +287,33 @@ class VoiceChannelCleanupService: guild = bot.get_guild(guild_id) if not guild: - self.logger.info(f"Guild {guild_id} not found, removing tracking for {channel_name}") + self.logger.info( + f"Guild {guild_id} not found, removing tracking for {channel_name}" + ) self.tracker.remove_channel(channel_id) return channel = guild.get_channel(channel_id) if not channel: - self.logger.info(f"Channel {channel_name} already deleted, removing from tracking") + self.logger.info( + f"Channel {channel_name} already deleted, removing from tracking" + ) self.tracker.remove_channel(channel_id) return # Ensure it's a voice channel before checking members if not isinstance(channel, discord.VoiceChannel): - self.logger.warning(f"Channel {channel_name} is not a voice channel, removing from tracking") + self.logger.warning( + f"Channel {channel_name} is not a voice channel, removing from tracking" + ) self.tracker.remove_channel(channel_id) return # Final check: make sure channel is still empty before deleting if len(channel.members) > 0: - self.logger.info(f"Channel {channel_name} is no longer empty, skipping cleanup") + self.logger.info( + f"Channel {channel_name} is no longer empty, skipping cleanup" + ) self.tracker.update_channel_status(channel_id, False) return @@ -272,24 +321,36 @@ class VoiceChannelCleanupService: await channel.delete(reason="Automatic cleanup - empty for 5+ minutes") self.tracker.remove_channel(channel_id) - self.logger.info(f"✅ Cleaned up empty voice channel: {channel_name} (ID: {channel_id})") + self.logger.info( + f"✅ Cleaned up empty voice channel: {channel_name} (ID: {channel_id})" + ) # Unpublish associated scorecard if it exists text_channel_id = channel_data.get("text_channel_id") if text_channel_id: try: text_channel_id_int = int(text_channel_id) - was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int) + was_unpublished = self.scorecard_tracker.unpublish_scorecard( + text_channel_id_int + ) if was_unpublished: - self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (voice channel cleanup)") + self.logger.info( + f"📋 Unpublished scorecard from text channel {text_channel_id_int} (voice channel cleanup)" + ) else: - self.logger.debug(f"No scorecard found for text channel {text_channel_id_int}") + self.logger.debug( + f"No scorecard found for text channel {text_channel_id_int}" + ) except (ValueError, TypeError) as e: - self.logger.warning(f"Invalid text_channel_id in voice channel data: {e}") + self.logger.warning( + f"Invalid text_channel_id in voice channel data: {e}" + ) except discord.NotFound: # Channel was already deleted - self.logger.info(f"Channel {channel_data.get('name', 'unknown')} was already deleted") + self.logger.info( + f"Channel {channel_data.get('name', 'unknown')} was already deleted" + ) self.tracker.remove_channel(int(channel_data["channel_id"])) # Still try to unpublish associated scorecard @@ -297,15 +358,25 @@ class VoiceChannelCleanupService: if text_channel_id: try: text_channel_id_int = int(text_channel_id) - was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int) + was_unpublished = self.scorecard_tracker.unpublish_scorecard( + text_channel_id_int + ) if was_unpublished: - self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel cleanup)") + self.logger.info( + f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel cleanup)" + ) except (ValueError, TypeError) as e: - self.logger.warning(f"Invalid text_channel_id in voice channel data: {e}") + self.logger.warning( + f"Invalid text_channel_id in voice channel data: {e}" + ) except discord.Forbidden: - self.logger.error(f"Missing permissions to delete channel {channel_data.get('name', 'unknown')}") + self.logger.error( + f"Missing permissions to delete channel {channel_data.get('name', 'unknown')}" + ) except Exception as e: - self.logger.error(f"Error cleaning up channel {channel_data.get('name', 'unknown')}: {e}") + self.logger.error( + f"Error cleaning up channel {channel_data.get('name', 'unknown')}: {e}" + ) def get_tracker(self) -> VoiceChannelTracker: """ @@ -330,7 +401,7 @@ class VoiceChannelCleanupService: "running": self.cleanup_loop.is_running(), "total_tracked": len(all_channels), "empty_channels": len(empty_channels), - "empty_threshold": self.empty_threshold + "empty_threshold": self.empty_threshold, } @@ -344,4 +415,4 @@ def setup_voice_cleanup(bot: commands.Bot) -> VoiceChannelCleanupService: Returns: VoiceChannelCleanupService instance """ - return VoiceChannelCleanupService(bot) \ No newline at end of file + return VoiceChannelCleanupService(bot) diff --git a/commands/voice/tracker.py b/commands/voice/tracker.py index 4e85080..3002d1d 100644 --- a/commands/voice/tracker.py +++ b/commands/voice/tracker.py @@ -3,6 +3,7 @@ Voice Channel Tracker Provides persistent tracking of bot-created voice channels using JSON file storage. """ + import json import logging from datetime import datetime, timedelta, UTC @@ -11,7 +12,7 @@ from typing import Dict, List, Optional, Any import discord -logger = logging.getLogger(f'{__name__}.VoiceChannelTracker') +logger = logging.getLogger(f"{__name__}.VoiceChannelTracker") class VoiceChannelTracker: @@ -25,7 +26,7 @@ class VoiceChannelTracker: - Automatic stale entry removal """ - def __init__(self, data_file: str = "data/voice_channels.json"): + def __init__(self, data_file: str = "storage/voice_channels.json"): """ Initialize the voice channel tracker. @@ -41,9 +42,11 @@ class VoiceChannelTracker: """Load channel data from JSON file.""" try: if self.data_file.exists(): - with open(self.data_file, 'r') as f: + with open(self.data_file, "r") as f: self._data = json.load(f) - logger.debug(f"Loaded {len(self._data.get('voice_channels', {}))} tracked channels") + logger.debug( + f"Loaded {len(self._data.get('voice_channels', {}))} tracked channels" + ) else: self._data = {"voice_channels": {}} logger.info("No existing voice channel data found, starting fresh") @@ -54,7 +57,7 @@ class VoiceChannelTracker: def save_data(self) -> None: """Save channel data to JSON file.""" try: - with open(self.data_file, 'w') as f: + with open(self.data_file, "w") as f: json.dump(self._data, f, indent=2, default=str) logger.debug("Voice channel data saved successfully") except Exception as e: @@ -65,7 +68,7 @@ class VoiceChannelTracker: channel: discord.VoiceChannel, channel_type: str, creator_id: int, - text_channel_id: Optional[int] = None + text_channel_id: Optional[int] = None, ) -> None: """ Add a new channel to tracking. @@ -85,7 +88,7 @@ class VoiceChannelTracker: "last_checked": datetime.now(UTC).isoformat(), "empty_since": None, "creator_id": str(creator_id), - "text_channel_id": str(text_channel_id) if text_channel_id else None + "text_channel_id": str(text_channel_id) if text_channel_id else None, } self.save_data() logger.info(f"Added channel to tracking: {channel.name} (ID: {channel.id})") @@ -130,9 +133,13 @@ class VoiceChannelTracker: channel_name = channels[channel_key]["name"] del channels[channel_key] self.save_data() - logger.info(f"Removed channel from tracking: {channel_name} (ID: {channel_id})") + logger.info( + f"Removed channel from tracking: {channel_name} (ID: {channel_id})" + ) - def get_channels_for_cleanup(self, empty_threshold_minutes: int = 15) -> List[Dict[str, Any]]: + def get_channels_for_cleanup( + self, empty_threshold_minutes: int = 15 + ) -> List[Dict[str, Any]]: """ Get channels that should be deleted based on empty duration. @@ -153,10 +160,12 @@ class VoiceChannelTracker: # Parse empty_since timestamp empty_since_str = channel_data["empty_since"] # Handle both with and without timezone info - if empty_since_str.endswith('Z'): - empty_since_str = empty_since_str[:-1] + '+00:00' + if empty_since_str.endswith("Z"): + empty_since_str = empty_since_str[:-1] + "+00:00" - empty_since = datetime.fromisoformat(empty_since_str.replace('Z', '+00:00')) + empty_since = datetime.fromisoformat( + empty_since_str.replace("Z", "+00:00") + ) # Remove timezone info for comparison (both times are UTC) if empty_since.tzinfo: @@ -164,10 +173,14 @@ class VoiceChannelTracker: if empty_since <= cutoff_time: cleanup_candidates.append(channel_data) - logger.debug(f"Channel {channel_data['name']} ready for cleanup (empty since {empty_since})") + logger.debug( + f"Channel {channel_data['name']} ready for cleanup (empty since {empty_since})" + ) except (ValueError, TypeError) as e: - logger.warning(f"Invalid timestamp for channel {channel_data.get('name', 'unknown')}: {e}") + logger.warning( + f"Invalid timestamp for channel {channel_data.get('name', 'unknown')}: {e}" + ) return cleanup_candidates @@ -242,9 +255,11 @@ class VoiceChannelTracker: for channel_id_str in stale_entries: channel_name = channels[channel_id_str].get("name", "unknown") del channels[channel_id_str] - logger.info(f"Removed stale tracking entry: {channel_name} (ID: {channel_id_str})") + logger.info( + f"Removed stale tracking entry: {channel_name} (ID: {channel_id_str})" + ) if stale_entries: self.save_data() - return len(stale_entries) \ No newline at end of file + return len(stale_entries) diff --git a/docker-compose.yml b/docker-compose.yml index 7c39ee1..f98d698 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,8 +36,11 @@ services: # Volume mounts volumes: - # Google Sheets credentials (required) - - ${SHEETS_CREDENTIALS_HOST_PATH:-./data}:/app/data:ro + # Google Sheets credentials (read-only, file mount) + - ${SHEETS_CREDENTIALS_HOST_PATH:-./data/major-domo-service-creds.json}:/app/data/major-domo-service-creds.json:ro + + # Runtime state files (writable) - scorecards, voice channels, trade channels, soak data + - ${STATE_HOST_PATH:-./storage}:/app/storage:rw # Logs directory (persistent) - mounted to /app/logs where the application expects it - ${LOGS_HOST_PATH:-./logs}:/app/logs:rw