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
|
# Project-specific
|
||||||
data/
|
data/
|
||||||
|
storage/
|
||||||
production_logs/
|
production_logs/
|
||||||
*.json
|
*.json
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user