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
All checks were successful
Build Docker Image / build (push) Successful in 1m9s
Reviewed-on: #86
This commit is contained in:
commit
18ab1393c0
1
.gitignore
vendored
1
.gitignore
vendored
@ -218,5 +218,6 @@ __marimo__/
|
||||
|
||||
# Project-specific
|
||||
data/
|
||||
storage/
|
||||
production_logs/
|
||||
*.json
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
return VoiceChannelCleanupService(bot)
|
||||
|
||||
@ -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)
|
||||
return len(stale_entries)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user