Merge pull request 'fix: split read-only data volume to allow state file writes (#85)' (#86) from ai/major-domo-v2-85 into next-release
All checks were successful
Build Docker Image / build (push) Successful in 1m9s

Reviewed-on: #86
This commit is contained in:
cal 2026-03-20 15:28:13 +00:00
commit 18ab1393c0
7 changed files with 189 additions and 91 deletions

1
.gitignore vendored
View File

@ -218,5 +218,6 @@ __marimo__/
# Project-specific # Project-specific
data/ data/
storage/
production_logs/ production_logs/
*.json *.json

View File

@ -24,7 +24,7 @@ class ScorecardTracker:
- Timestamp tracking for monitoring - 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. Initialize the scorecard tracker.

View File

@ -3,13 +3,14 @@ Soak Tracker
Provides persistent tracking of "soak" mentions using JSON file storage. Provides persistent tracking of "soak" mentions using JSON file storage.
""" """
import json import json
import logging import logging
from datetime import datetime, timedelta, UTC from datetime import datetime, timedelta, UTC
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
logger = logging.getLogger(f'{__name__}.SoakTracker') logger = logging.getLogger(f"{__name__}.SoakTracker")
class SoakTracker: class SoakTracker:
@ -22,7 +23,7 @@ class SoakTracker:
- Time-based calculations for disappointment tiers - 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. Initialize the soak tracker.
@ -38,28 +39,22 @@ class SoakTracker:
"""Load soak data from JSON file.""" """Load soak data from JSON file."""
try: try:
if self.data_file.exists(): 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) 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: else:
self._data = { self._data = {"last_soak": None, "total_count": 0, "history": []}
"last_soak": None,
"total_count": 0,
"history": []
}
logger.info("No existing soak data found, starting fresh") logger.info("No existing soak data found, starting fresh")
except Exception as e: except Exception as e:
logger.error(f"Failed to load soak data: {e}") logger.error(f"Failed to load soak data: {e}")
self._data = { self._data = {"last_soak": None, "total_count": 0, "history": []}
"last_soak": None,
"total_count": 0,
"history": []
}
def save_data(self) -> None: def save_data(self) -> None:
"""Save soak data to JSON file.""" """Save soak data to JSON file."""
try: 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) json.dump(self._data, f, indent=2, default=str)
logger.debug("Soak data saved successfully") logger.debug("Soak data saved successfully")
except Exception as e: except Exception as e:
@ -71,7 +66,7 @@ class SoakTracker:
username: str, username: str,
display_name: str, display_name: str,
channel_id: int, channel_id: int,
message_id: int message_id: int,
) -> None: ) -> None:
""" """
Record a new soak mention. Record a new soak mention.
@ -89,7 +84,7 @@ class SoakTracker:
"username": username, "username": username,
"display_name": display_name, "display_name": display_name,
"channel_id": str(channel_id), "channel_id": str(channel_id),
"message_id": str(message_id) "message_id": str(message_id),
} }
# Update last_soak # Update last_soak
@ -110,7 +105,9 @@ class SoakTracker:
self.save_data() 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]]: def get_last_soak(self) -> Optional[Dict[str, Any]]:
""" """
@ -135,10 +132,12 @@ class SoakTracker:
try: try:
# Parse ISO format timestamp # Parse ISO format timestamp
last_timestamp_str = last_soak["timestamp"] last_timestamp_str = last_soak["timestamp"]
if last_timestamp_str.endswith('Z'): if last_timestamp_str.endswith("Z"):
last_timestamp_str = last_timestamp_str[:-1] + '+00:00' 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 # Ensure both times are timezone-aware
if last_timestamp.tzinfo is None: if last_timestamp.tzinfo is None:

View File

@ -3,6 +3,7 @@ Trade Channel Tracker
Provides persistent tracking of bot-created trade discussion channels using JSON file storage. Provides persistent tracking of bot-created trade discussion channels using JSON file storage.
""" """
import json import json
from datetime import datetime, UTC from datetime import datetime, UTC
from pathlib import Path from pathlib import Path
@ -12,7 +13,7 @@ import discord
from utils.logging import get_contextual_logger from utils.logging import get_contextual_logger
logger = get_contextual_logger(f'{__name__}.TradeChannelTracker') logger = get_contextual_logger(f"{__name__}.TradeChannelTracker")
class TradeChannelTracker: class TradeChannelTracker:
@ -26,7 +27,7 @@ class TradeChannelTracker:
- Automatic stale entry removal - 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. Initialize the trade channel tracker.
@ -42,9 +43,11 @@ class TradeChannelTracker:
"""Load channel data from JSON file.""" """Load channel data from JSON file."""
try: try:
if self.data_file.exists(): 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) 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: else:
self._data = {"trade_channels": {}} self._data = {"trade_channels": {}}
logger.info("No existing trade channel data found, starting fresh") logger.info("No existing trade channel data found, starting fresh")
@ -55,7 +58,7 @@ class TradeChannelTracker:
def save_data(self) -> None: def save_data(self) -> None:
"""Save channel data to JSON file.""" """Save channel data to JSON file."""
try: 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) json.dump(self._data, f, indent=2, default=str)
logger.debug("Trade channel data saved successfully") logger.debug("Trade channel data saved successfully")
except Exception as e: except Exception as e:
@ -67,7 +70,7 @@ class TradeChannelTracker:
trade_id: str, trade_id: str,
team1_abbrev: str, team1_abbrev: str,
team2_abbrev: str, team2_abbrev: str,
creator_id: int creator_id: int,
) -> None: ) -> None:
""" """
Add a new trade channel to tracking. Add a new trade channel to tracking.
@ -87,10 +90,12 @@ class TradeChannelTracker:
"team1_abbrev": team1_abbrev, "team1_abbrev": team1_abbrev,
"team2_abbrev": team2_abbrev, "team2_abbrev": team2_abbrev,
"created_at": datetime.now(UTC).isoformat(), "created_at": datetime.now(UTC).isoformat(),
"creator_id": str(creator_id) "creator_id": str(creator_id),
} }
self.save_data() 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: def remove_channel(self, channel_id: int) -> None:
""" """
@ -108,7 +113,9 @@ class TradeChannelTracker:
channel_name = channel_data["name"] channel_name = channel_data["name"]
del channels[channel_key] del channels[channel_key]
self.save_data() 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]]: 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") channel_name = channels[channel_id_str].get("name", "unknown")
trade_id = channels[channel_id_str].get("trade_id", "unknown") trade_id = channels[channel_id_str].get("trade_id", "unknown")
del channels[channel_id_str] 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: if stale_entries:
self.save_data() self.save_data()

View File

@ -3,6 +3,7 @@ Voice Channel Cleanup Service
Provides automatic cleanup of empty voice channels with restart resilience. Provides automatic cleanup of empty voice channels with restart resilience.
""" """
import logging import logging
import discord import discord
@ -12,7 +13,7 @@ from .tracker import VoiceChannelTracker
from commands.gameplay.scorecard_tracker import ScorecardTracker from commands.gameplay.scorecard_tracker import ScorecardTracker
from utils.logging import get_contextual_logger from utils.logging import get_contextual_logger
logger = logging.getLogger(f'{__name__}.VoiceChannelCleanupService') logger = logging.getLogger(f"{__name__}.VoiceChannelCleanupService")
class VoiceChannelCleanupService: class VoiceChannelCleanupService:
@ -27,7 +28,9 @@ class VoiceChannelCleanupService:
- Automatic scorecard unpublishing when voice channel is cleaned up - 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. Initialize the cleanup service.
@ -36,10 +39,10 @@ class VoiceChannelCleanupService:
data_file: Path to the JSON data file for persistence data_file: Path to the JSON data file for persistence
""" """
self.bot = bot 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.tracker = VoiceChannelTracker(data_file)
self.scorecard_tracker = ScorecardTracker() 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 # Start the cleanup task - @before_loop will wait for bot readiness
self.cleanup_loop.start() self.cleanup_loop.start()
@ -90,13 +93,17 @@ class VoiceChannelCleanupService:
guild = bot.get_guild(guild_id) guild = bot.get_guild(guild_id)
if not guild: 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) channels_to_remove.append(channel_id)
continue continue
channel = guild.get_channel(channel_id) channel = guild.get_channel(channel_id)
if not channel: 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) channels_to_remove.append(channel_id)
continue continue
@ -121,18 +128,26 @@ class VoiceChannelCleanupService:
if channel_data and channel_data.get("text_channel_id"): if channel_data and channel_data.get("text_channel_id"):
try: try:
text_channel_id_int = int(channel_data["text_channel_id"]) 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: 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: 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 # Also clean up any additional stale entries
stale_removed = self.tracker.cleanup_stale_entries(valid_channel_ids) stale_removed = self.tracker.cleanup_stale_entries(valid_channel_ids)
total_removed = len(channels_to_remove) + stale_removed total_removed = len(channels_to_remove) + stale_removed
if total_removed > 0: 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") 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) await self.update_all_channel_statuses(bot)
# Get channels ready for cleanup # 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: 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 # Delete empty channels
for channel_data in channels_for_cleanup: for channel_data in channels_for_cleanup:
@ -182,12 +201,16 @@ class VoiceChannelCleanupService:
guild = bot.get_guild(guild_id) guild = bot.get_guild(guild_id)
if not guild: 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 return
channel = guild.get_channel(channel_id) channel = guild.get_channel(channel_id)
if not channel: 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) self.tracker.remove_channel(channel_id)
# Unpublish associated scorecard if it exists # Unpublish associated scorecard if it exists
@ -195,17 +218,25 @@ class VoiceChannelCleanupService:
if text_channel_id: if text_channel_id:
try: try:
text_channel_id_int = int(text_channel_id) 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: 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: 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 return
# Ensure it's a voice channel before checking members # Ensure it's a voice channel before checking members
if not isinstance(channel, discord.VoiceChannel): 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) self.tracker.remove_channel(channel_id)
# Unpublish associated scorecard if it exists # Unpublish associated scorecard if it exists
@ -213,11 +244,17 @@ class VoiceChannelCleanupService:
if text_channel_id: if text_channel_id:
try: try:
text_channel_id_int = int(text_channel_id) 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: 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: 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 return
@ -225,11 +262,15 @@ class VoiceChannelCleanupService:
is_empty = len(channel.members) == 0 is_empty = len(channel.members) == 0
self.tracker.update_channel_status(channel_id, is_empty) self.tracker.update_channel_status(channel_id, is_empty)
self.logger.debug(f"Channel {channel_data['name']}: {'empty' if is_empty else 'occupied'} " self.logger.debug(
f"({len(channel.members)} members)") f"Channel {channel_data['name']}: {'empty' if is_empty else 'occupied'} "
f"({len(channel.members)} members)"
)
except Exception as e: 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: async def cleanup_channel(self, bot: commands.Bot, channel_data: dict) -> None:
""" """
@ -246,25 +287,33 @@ class VoiceChannelCleanupService:
guild = bot.get_guild(guild_id) guild = bot.get_guild(guild_id)
if not guild: 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) self.tracker.remove_channel(channel_id)
return return
channel = guild.get_channel(channel_id) channel = guild.get_channel(channel_id)
if not channel: 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) self.tracker.remove_channel(channel_id)
return return
# Ensure it's a voice channel before checking members # Ensure it's a voice channel before checking members
if not isinstance(channel, discord.VoiceChannel): 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) self.tracker.remove_channel(channel_id)
return return
# Final check: make sure channel is still empty before deleting # Final check: make sure channel is still empty before deleting
if len(channel.members) > 0: 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) self.tracker.update_channel_status(channel_id, False)
return return
@ -272,24 +321,36 @@ class VoiceChannelCleanupService:
await channel.delete(reason="Automatic cleanup - empty for 5+ minutes") await channel.delete(reason="Automatic cleanup - empty for 5+ minutes")
self.tracker.remove_channel(channel_id) 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 # Unpublish associated scorecard if it exists
text_channel_id = channel_data.get("text_channel_id") text_channel_id = channel_data.get("text_channel_id")
if text_channel_id: if text_channel_id:
try: try:
text_channel_id_int = int(text_channel_id) 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: 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: 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: 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: except discord.NotFound:
# Channel was already deleted # 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"])) self.tracker.remove_channel(int(channel_data["channel_id"]))
# Still try to unpublish associated scorecard # Still try to unpublish associated scorecard
@ -297,15 +358,25 @@ class VoiceChannelCleanupService:
if text_channel_id: if text_channel_id:
try: try:
text_channel_id_int = int(text_channel_id) 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: 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: 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: 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: 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: def get_tracker(self) -> VoiceChannelTracker:
""" """
@ -330,7 +401,7 @@ class VoiceChannelCleanupService:
"running": self.cleanup_loop.is_running(), "running": self.cleanup_loop.is_running(),
"total_tracked": len(all_channels), "total_tracked": len(all_channels),
"empty_channels": len(empty_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: Returns:
VoiceChannelCleanupService instance VoiceChannelCleanupService instance
""" """
return VoiceChannelCleanupService(bot) return VoiceChannelCleanupService(bot)

View File

@ -3,6 +3,7 @@ Voice Channel Tracker
Provides persistent tracking of bot-created voice channels using JSON file storage. Provides persistent tracking of bot-created voice channels using JSON file storage.
""" """
import json import json
import logging import logging
from datetime import datetime, timedelta, UTC from datetime import datetime, timedelta, UTC
@ -11,7 +12,7 @@ from typing import Dict, List, Optional, Any
import discord import discord
logger = logging.getLogger(f'{__name__}.VoiceChannelTracker') logger = logging.getLogger(f"{__name__}.VoiceChannelTracker")
class VoiceChannelTracker: class VoiceChannelTracker:
@ -25,7 +26,7 @@ class VoiceChannelTracker:
- Automatic stale entry removal - 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. Initialize the voice channel tracker.
@ -41,9 +42,11 @@ class VoiceChannelTracker:
"""Load channel data from JSON file.""" """Load channel data from JSON file."""
try: try:
if self.data_file.exists(): 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) 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: else:
self._data = {"voice_channels": {}} self._data = {"voice_channels": {}}
logger.info("No existing voice channel data found, starting fresh") logger.info("No existing voice channel data found, starting fresh")
@ -54,7 +57,7 @@ class VoiceChannelTracker:
def save_data(self) -> None: def save_data(self) -> None:
"""Save channel data to JSON file.""" """Save channel data to JSON file."""
try: 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) json.dump(self._data, f, indent=2, default=str)
logger.debug("Voice channel data saved successfully") logger.debug("Voice channel data saved successfully")
except Exception as e: except Exception as e:
@ -65,7 +68,7 @@ class VoiceChannelTracker:
channel: discord.VoiceChannel, channel: discord.VoiceChannel,
channel_type: str, channel_type: str,
creator_id: int, creator_id: int,
text_channel_id: Optional[int] = None text_channel_id: Optional[int] = None,
) -> None: ) -> None:
""" """
Add a new channel to tracking. Add a new channel to tracking.
@ -85,7 +88,7 @@ class VoiceChannelTracker:
"last_checked": datetime.now(UTC).isoformat(), "last_checked": datetime.now(UTC).isoformat(),
"empty_since": None, "empty_since": None,
"creator_id": str(creator_id), "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() self.save_data()
logger.info(f"Added channel to tracking: {channel.name} (ID: {channel.id})") logger.info(f"Added channel to tracking: {channel.name} (ID: {channel.id})")
@ -130,9 +133,13 @@ class VoiceChannelTracker:
channel_name = channels[channel_key]["name"] channel_name = channels[channel_key]["name"]
del channels[channel_key] del channels[channel_key]
self.save_data() 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. Get channels that should be deleted based on empty duration.
@ -153,10 +160,12 @@ class VoiceChannelTracker:
# Parse empty_since timestamp # Parse empty_since timestamp
empty_since_str = channel_data["empty_since"] empty_since_str = channel_data["empty_since"]
# Handle both with and without timezone info # Handle both with and without timezone info
if empty_since_str.endswith('Z'): if empty_since_str.endswith("Z"):
empty_since_str = empty_since_str[:-1] + '+00:00' 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) # Remove timezone info for comparison (both times are UTC)
if empty_since.tzinfo: if empty_since.tzinfo:
@ -164,10 +173,14 @@ class VoiceChannelTracker:
if empty_since <= cutoff_time: if empty_since <= cutoff_time:
cleanup_candidates.append(channel_data) 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: 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 return cleanup_candidates
@ -242,9 +255,11 @@ class VoiceChannelTracker:
for channel_id_str in stale_entries: for channel_id_str in stale_entries:
channel_name = channels[channel_id_str].get("name", "unknown") channel_name = channels[channel_id_str].get("name", "unknown")
del channels[channel_id_str] 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: if stale_entries:
self.save_data() self.save_data()
return len(stale_entries) return len(stale_entries)

View File

@ -36,8 +36,11 @@ services:
# Volume mounts # Volume mounts
volumes: volumes:
# Google Sheets credentials (required) # Google Sheets credentials (read-only, file mount)
- ${SHEETS_CREDENTIALS_HOST_PATH:-./data}:/app/data:ro - ${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 directory (persistent) - mounted to /app/logs where the application expects it
- ${LOGS_HOST_PATH:-./logs}:/app/logs:rw - ${LOGS_HOST_PATH:-./logs}:/app/logs:rw