Files
miku-discord/bot/server_manager.py

694 lines
32 KiB
Python
Raw Normal View History

2025-12-07 17:15:09 +02:00
# server_manager.py
import json
import os
import asyncio
from typing import Dict, List, Optional, Set
from dataclasses import dataclass, asdict, fields as dataclass_fields
2025-12-07 17:15:09 +02:00
from datetime import datetime, timedelta
import discord
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
import random
from utils.logger import get_logger
logger = get_logger('server')
2025-12-07 17:15:09 +02:00
@dataclass
class ServerConfig:
"""Configuration for a single Discord server"""
guild_id: int
guild_name: str
autonomous_channel_id: int
autonomous_channel_name: str
bedtime_channel_ids: List[int]
enabled_features: Set[str] # autonomous, bedtime, monday_video, etc.
autonomous_interval_minutes: int = 15
conversation_detection_interval_minutes: int = 3
bedtime_hour: int = 21
bedtime_minute: int = 0
bedtime_hour_end: int = 21 # End of bedtime range (default 11PM)
bedtime_minute_end: int = 59 # End of bedtime range (default 11:59PM)
monday_video_hour: int = 4
monday_video_minute: int = 30
# Per-server mood tracking
current_mood_name: str = "neutral"
current_mood_description: str = ""
previous_mood_name: str = "neutral"
is_sleeping: bool = False
sleepy_responses_left: Optional[int] = None
angry_wakeup_timer: Optional[float] = None # Unused, kept for structural completeness
forced_angry_until: Optional[str] = None # ISO format datetime string, or None
2025-12-07 17:15:09 +02:00
just_woken_up: bool = False
def to_dict(self):
return asdict(self)
@classmethod
def from_dict(cls, data: dict):
# Convert set back from list, or handle old string format
if 'enabled_features' in data:
if isinstance(data['enabled_features'], list):
data['enabled_features'] = set(data['enabled_features'])
elif isinstance(data['enabled_features'], str):
# Handle old string format like "{'bedtime', 'monday_video', 'autonomous'}"
try:
# Remove the outer braces and split by comma
features_str = data['enabled_features'].strip('{}')
features_list = [f.strip().strip("'\"") for f in features_str.split(',') if f.strip()]
data['enabled_features'] = set(features_list)
except Exception as e:
logger.warning(f"Failed to parse enabled_features string '{data['enabled_features']}': {e}")
2025-12-07 17:15:09 +02:00
# Fallback to default features
data['enabled_features'] = {"autonomous", "bedtime", "monday_video"}
# Strip any keys that aren't valid dataclass fields (forward-compat safety)
valid_fields = {f.name for f in dataclass_fields(cls)}
data = {k: v for k, v in data.items() if k in valid_fields}
2025-12-07 17:15:09 +02:00
return cls(**data)
class ServerManager:
"""Manages multiple Discord servers with independent configurations"""
def __init__(self, config_file: str = "memory/servers_config.json"):
self.config_file = config_file
self.servers: Dict[int, ServerConfig] = {}
self.schedulers: Dict[int, AsyncIOScheduler] = {}
self._wakeup_tasks: Dict[int, asyncio.Task] = {} # guild_id -> delayed wakeup task
2025-12-07 17:15:09 +02:00
self.load_config()
def load_config(self):
"""Load server configurations from file"""
if os.path.exists(self.config_file):
try:
with open(self.config_file, "r", encoding="utf-8") as f:
data = json.load(f)
for guild_id_str, server_data in data.items():
guild_id = int(guild_id_str)
self.servers[guild_id] = ServerConfig.from_dict(server_data)
logger.info(f"Loaded config for server: {server_data['guild_name']} (ID: {guild_id})")
2025-12-07 17:15:09 +02:00
# After loading, check if we need to repair the config
self.repair_config()
except Exception as e:
logger.error(f"Failed to load server config: {e}")
logger.info("Starting with zero servers — add servers via the API or dashboard")
2025-12-07 17:15:09 +02:00
else:
logger.info("No servers_config.json found — starting with zero servers")
2025-12-07 17:15:09 +02:00
def repair_config(self):
"""Repair corrupted configuration data and save it back"""
try:
needs_repair = False
for server in self.servers.values():
# Check if enabled_features is a string (corrupted)
if isinstance(server.enabled_features, str):
needs_repair = True
logger.info(f"Repairing corrupted enabled_features for server: {server.guild_name}")
2025-12-07 17:15:09 +02:00
# Re-parse the features
try:
features_str = server.enabled_features.strip('{}')
features_list = [f.strip().strip("'\"") for f in features_str.split(',') if f.strip()]
server.enabled_features = set(features_list)
except Exception as e:
logger.warning(f"Failed to repair enabled_features for {server.guild_name}: {e}")
2025-12-07 17:15:09 +02:00
server.enabled_features = {"autonomous", "bedtime", "monday_video"}
if needs_repair:
logger.info("Saving repaired configuration...")
2025-12-07 17:15:09 +02:00
self.save_config()
except Exception as e:
logger.error(f"Failed to repair config: {e}")
2025-12-07 17:15:09 +02:00
def save_config(self):
"""Save server configurations to file"""
try:
os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
config_data = {}
for guild_id, server in self.servers.items():
# Convert the server config to dict, but handle sets properly
server_dict = server.to_dict()
# Convert set to list for JSON serialization
if 'enabled_features' in server_dict and isinstance(server_dict['enabled_features'], set):
server_dict['enabled_features'] = list(server_dict['enabled_features'])
config_data[str(guild_id)] = server_dict
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump(config_data, f, indent=2)
except Exception as e:
logger.error(f"Failed to save server config: {e}")
2025-12-07 17:15:09 +02:00
def add_server(self, guild_id: int, guild_name: str, autonomous_channel_id: int,
autonomous_channel_name: str, bedtime_channel_ids: List[int] = None,
enabled_features: Set[str] = None) -> bool:
"""Add a new server configuration"""
if guild_id in self.servers:
logger.info(f"Server {guild_id} already exists")
2025-12-07 17:15:09 +02:00
return False
if bedtime_channel_ids is None:
bedtime_channel_ids = [autonomous_channel_id]
if enabled_features is None:
enabled_features = {"autonomous", "bedtime", "monday_video"}
server = ServerConfig(
guild_id=guild_id,
guild_name=guild_name,
autonomous_channel_id=autonomous_channel_id,
autonomous_channel_name=autonomous_channel_name,
bedtime_channel_ids=bedtime_channel_ids,
enabled_features=enabled_features
)
self.servers[guild_id] = server
self.save_config()
logger.info(f"Added new server: {guild_name} (ID: {guild_id})")
2025-12-07 17:15:09 +02:00
return True
def remove_server(self, guild_id: int) -> bool:
"""Remove a server configuration"""
if guild_id not in self.servers:
return False
server_name = self.servers[guild_id].guild_name
del self.servers[guild_id]
# Stop and remove scheduler
if guild_id in self.schedulers:
self.schedulers[guild_id].shutdown()
del self.schedulers[guild_id]
self.save_config()
logger.info(f"Removed server: {server_name} (ID: {guild_id})")
2025-12-07 17:15:09 +02:00
return True
def get_server_config(self, guild_id: int) -> Optional[ServerConfig]:
"""Get configuration for a specific server"""
return self.servers.get(guild_id)
def get_all_servers(self) -> List[ServerConfig]:
"""Get all server configurations"""
return list(self.servers.values())
def update_server_config(self, guild_id: int, **kwargs) -> bool:
"""Update configuration for a specific server"""
if guild_id not in self.servers:
return False
server = self.servers[guild_id]
for key, value in kwargs.items():
if hasattr(server, key):
setattr(server, key, value)
self.save_config()
logger.info(f"Updated config for server: {server.guild_name}")
2025-12-07 17:15:09 +02:00
return True
# ========== Mood Management Methods ==========
def get_server_mood(self, guild_id: int) -> tuple[str, str]:
"""Get current mood name and description for a server"""
if guild_id not in self.servers:
return "neutral", ""
server = self.servers[guild_id]
return server.current_mood_name, server.current_mood_description
def set_server_mood(self, guild_id: int, mood_name: str, mood_description: str = None):
"""Set mood for a specific server.
Also handles:
- Syncing is_sleeping state (fix #4: sleep/mood desync)
- Notifying the autonomous engine (fix #9: engine mood desync)
"""
2025-12-07 17:15:09 +02:00
if guild_id not in self.servers:
return False
server = self.servers[guild_id]
server.previous_mood_name = server.current_mood_name
server.current_mood_name = mood_name
if mood_description:
server.current_mood_description = mood_description
else:
# Load mood description if not provided
try:
from utils.moods import load_mood_description
server.current_mood_description = load_mood_description(mood_name)
except Exception as e:
logger.error(f"Failed to load mood description for {mood_name}: {e}")
2025-12-07 17:15:09 +02:00
server.current_mood_description = f"I'm feeling {mood_name} today."
# Fix #4: Keep is_sleeping in sync with mood
# If mood changes away from 'asleep', clear sleeping state
if mood_name != "asleep" and server.is_sleeping:
server.is_sleeping = False
self.cancel_wakeup_task(guild_id)
logger.info(f"Cleared sleep state for server {server.guild_name} (mood changed to {mood_name})")
2025-12-07 17:15:09 +02:00
self.save_config()
logger.info(f"Server {server.guild_name} mood changed to: {mood_name}")
logger.debug(f"Mood description: {server.current_mood_description[:100]}{'...' if len(server.current_mood_description) > 100 else ''}")
# Fix #9: Always notify autonomous engine of mood change
try:
from utils.autonomous import on_mood_change
on_mood_change(guild_id, mood_name)
except Exception as e:
logger.error(f"Failed to notify autonomous engine of mood change to {mood_name}: {e}")
2025-12-07 17:15:09 +02:00
return True
def get_server_sleep_state(self, guild_id: int) -> bool:
"""Get sleep state for a specific server"""
if guild_id not in self.servers:
return False
return self.servers[guild_id].is_sleeping
def set_server_sleep_state(self, guild_id: int, sleeping: bool):
"""Set sleep state for a specific server"""
if guild_id not in self.servers:
return False
server = self.servers[guild_id]
server.is_sleeping = sleeping
# If waking up, cancel any pending delayed wakeup task
if not sleeping:
self.cancel_wakeup_task(guild_id)
2025-12-07 17:15:09 +02:00
self.save_config()
return True
def schedule_wakeup_task(self, guild_id: int, delay_seconds: int = 3600):
"""Schedule a delayed wakeup task for a server, cancelling any existing one first.
Args:
guild_id: The server to schedule wakeup for
delay_seconds: How long to sleep before waking (default 1 hour)
"""
# Cancel any existing wakeup task for this server
self.cancel_wakeup_task(guild_id)
import globals as _globals
async def _delayed_wakeup():
try:
await asyncio.sleep(delay_seconds)
# Check if we're still asleep (might have been woken manually)
server = self.servers.get(guild_id)
if server and server.is_sleeping:
self.set_server_sleep_state(guild_id, False)
self.set_server_mood(guild_id, "neutral")
# Update nickname
try:
from utils.moods import update_server_nickname
await update_server_nickname(guild_id)
except Exception as e:
logger.error(f"Failed to update nickname on wake-up: {e}")
logger.info(f"Server {guild_id} woke up from auto-sleep after {delay_seconds}s")
else:
logger.debug(f"Wakeup task for {guild_id} completed but server already awake, skipping")
except asyncio.CancelledError:
logger.debug(f"Wakeup task for server {guild_id} was cancelled")
finally:
# Clean up our reference
self._wakeup_tasks.pop(guild_id, None)
task = _globals.client.loop.create_task(_delayed_wakeup())
self._wakeup_tasks[guild_id] = task
logger.info(f"Scheduled auto-wake for server {guild_id} in {delay_seconds}s")
return task
def cancel_wakeup_task(self, guild_id: int):
"""Cancel a pending wakeup task for a server, if any."""
task = self._wakeup_tasks.pop(guild_id, None)
if task and not task.done():
task.cancel()
logger.info(f"Cancelled pending wakeup task for server {guild_id}")
2025-12-07 17:15:09 +02:00
def get_server_mood_state(self, guild_id: int) -> dict:
"""Get complete mood state for a specific server"""
if guild_id not in self.servers:
return {}
server = self.servers[guild_id]
return {
"current_mood_name": server.current_mood_name,
"current_mood_description": server.current_mood_description,
"previous_mood_name": server.previous_mood_name,
"is_sleeping": server.is_sleeping,
"sleepy_responses_left": server.sleepy_responses_left,
"forced_angry_until": server.forced_angry_until,
"just_woken_up": server.just_woken_up
}
def set_server_mood_state(self, guild_id: int, **kwargs):
"""Set multiple mood state properties for a server"""
if guild_id not in self.servers:
return False
server = self.servers[guild_id]
for key, value in kwargs.items():
if hasattr(server, key):
setattr(server, key, value)
self.save_config()
return True
def setup_server_scheduler(self, guild_id: int, client: discord.Client):
"""Setup independent scheduler for a specific server"""
if guild_id not in self.servers:
logger.warning(f"Cannot setup scheduler for unknown server: {guild_id}")
2025-12-07 17:15:09 +02:00
return
server_config = self.servers[guild_id]
# Create new scheduler for this server
scheduler = AsyncIOScheduler()
# Add autonomous speaking job
if "autonomous" in server_config.enabled_features:
scheduler.add_job(
self._run_autonomous_for_server,
IntervalTrigger(minutes=server_config.autonomous_interval_minutes),
args=[guild_id, client],
id=f"autonomous_{guild_id}"
)
# Add autonomous reaction job (parallel to speaking, runs every 20 minutes)
if "autonomous" in server_config.enabled_features:
scheduler.add_job(
self._run_autonomous_reaction_for_server,
IntervalTrigger(minutes=20),
args=[guild_id, client],
id=f"autonomous_reaction_{guild_id}"
)
# Note: Conversation detection is now handled by V2 system via message events
# No need for separate scheduler job
# Add Monday video job
if "monday_video" in server_config.enabled_features:
scheduler.add_job(
self._send_monday_video_for_server,
CronTrigger(day_of_week='mon', hour=server_config.monday_video_hour, minute=server_config.monday_video_minute),
args=[guild_id, client],
id=f"monday_video_{guild_id}"
)
# Add bedtime reminder job
if "bedtime" in server_config.enabled_features:
logger.info(f"Setting up bedtime scheduler for server {server_config.guild_name}")
logger.debug(f" Random time range: {server_config.bedtime_hour:02d}:{server_config.bedtime_minute:02d} - {server_config.bedtime_hour_end:02d}:{server_config.bedtime_minute_end:02d}")
2025-12-07 17:15:09 +02:00
scheduler.add_job(
self._schedule_random_bedtime_for_server,
CronTrigger(hour=server_config.bedtime_hour, minute=server_config.bedtime_minute),
args=[guild_id, client],
id=f"bedtime_schedule_{guild_id}"
)
# Add mood rotation job (every hour)
scheduler.add_job(
self._rotate_server_mood,
IntervalTrigger(hours=1),
args=[guild_id, client],
id=f"mood_rotation_{guild_id}"
)
self.schedulers[guild_id] = scheduler
scheduler.start()
logger.info(f"Started scheduler for server: {server_config.guild_name}")
2025-12-07 17:15:09 +02:00
def start_all_schedulers(self, client: discord.Client):
"""Start schedulers for all servers"""
logger.info("Starting all server schedulers...")
2025-12-07 17:15:09 +02:00
for guild_id in self.servers:
self.setup_server_scheduler(guild_id, client)
# Start DM mood rotation scheduler
self.setup_dm_mood_scheduler(client)
# Start Figurine DM scheduler
self.setup_figurine_updates_scheduler(client)
logger.info(f"Started {len(self.servers)} server schedulers + DM mood scheduler")
2025-12-07 17:15:09 +02:00
def update_server_bedtime_job(self, guild_id: int, client: discord.Client):
"""Update just the bedtime job for a specific server without restarting all schedulers"""
server_config = self.servers.get(guild_id)
if not server_config:
logger.warning(f"No server config found for guild {guild_id}")
2025-12-07 17:15:09 +02:00
return False
scheduler = self.schedulers.get(guild_id)
if not scheduler:
logger.warning(f"No scheduler found for guild {guild_id}")
2025-12-07 17:15:09 +02:00
return False
# Remove existing bedtime job if it exists
bedtime_job_id = f"bedtime_schedule_{guild_id}"
try:
scheduler.remove_job(bedtime_job_id)
logger.info(f"Removed old bedtime job for server {guild_id}")
2025-12-07 17:15:09 +02:00
except Exception as e:
logger.debug(f"No existing bedtime job to remove for server {guild_id}: {e}")
2025-12-07 17:15:09 +02:00
# Add new bedtime job with updated configuration
if "bedtime" in server_config.enabled_features:
logger.info(f"Updating bedtime scheduler for server {server_config.guild_name}")
logger.debug(f" New random time range: {server_config.bedtime_hour:02d}:{server_config.bedtime_minute:02d} - {server_config.bedtime_hour_end:02d}:{server_config.bedtime_minute_end:02d}")
2025-12-07 17:15:09 +02:00
scheduler.add_job(
self._schedule_random_bedtime_for_server,
CronTrigger(hour=server_config.bedtime_hour, minute=server_config.bedtime_minute),
args=[guild_id, client],
id=bedtime_job_id
)
logger.info(f"Updated bedtime job for server {server_config.guild_name}")
2025-12-07 17:15:09 +02:00
return True
else:
logger.info(f"Bedtime feature not enabled for server {guild_id}")
2025-12-07 17:15:09 +02:00
return True
def setup_dm_mood_scheduler(self, client: discord.Client):
"""Setup DM mood rotation scheduler"""
try:
from utils.moods import rotate_dm_mood
# Create DM mood rotation job (every 2 hours)
scheduler = AsyncIOScheduler()
scheduler.add_job(
rotate_dm_mood,
IntervalTrigger(hours=2),
id="dm_mood_rotation"
)
scheduler.start()
self.schedulers["dm_mood"] = scheduler
logger.info("DM mood rotation scheduler started (every 2 hours)")
2025-12-07 17:15:09 +02:00
except Exception as e:
logger.error(f"Failed to setup DM mood scheduler: {e}")
2025-12-07 17:15:09 +02:00
def _enqueue_figurine_send(self, client: discord.Client):
"""Enqueue the figurine DM send task in the client's loop."""
try:
from utils.figurine_notifier import send_figurine_dm_to_all_subscribers
if client.loop and client.loop.is_running():
client.loop.create_task(send_figurine_dm_to_all_subscribers(client))
logger.debug("Figurine DM send task queued")
2025-12-07 17:15:09 +02:00
else:
logger.warning("Client loop not available for figurine DM send")
2025-12-07 17:15:09 +02:00
except Exception as e:
logger.error(f"Error enqueuing figurine DM: {e}")
2025-12-07 17:15:09 +02:00
def _schedule_one_figurine_send_today(self, scheduler: AsyncIOScheduler, client: discord.Client):
"""Schedule one figurine DM send at a random non-evening time today (or tomorrow if time passed)."""
now = datetime.now()
# Define non-evening hours: 08:00-17:59
random_hour = random.randint(8, 17)
random_minute = random.randint(0, 59)
target_time = now.replace(hour=random_hour, minute=random_minute, second=0, microsecond=0)
if target_time <= now:
target_time = target_time + timedelta(days=1)
logger.info(f"Scheduling figurine DM at {target_time.strftime('%Y-%m-%d %H:%M')} (random non-evening)")
2025-12-07 17:15:09 +02:00
scheduler.add_job(
self._enqueue_figurine_send,
DateTrigger(run_date=target_time),
args=[client],
id=f"figurine_dm_{int(target_time.timestamp())}",
replace_existing=False
)
def setup_figurine_updates_scheduler(self, client: discord.Client):
"""Create a daily scheduler that schedules one random non-evening figurine DM send per day."""
try:
scheduler = AsyncIOScheduler()
# Every day at 07:30, schedule today's random send (will roll to tomorrow if time passed)
scheduler.add_job(
self._schedule_one_figurine_send_today,
CronTrigger(hour=7, minute=30),
args=[scheduler, client],
id="figurine_daily_scheduler"
)
# Also schedule one immediately on startup for today/tomorrow
self._schedule_one_figurine_send_today(scheduler, client)
scheduler.start()
self.schedulers["figurine_dm"] = scheduler
logger.info("Figurine updates scheduler started")
2025-12-07 17:15:09 +02:00
except Exception as e:
logger.error(f"Failed to setup figurine updates scheduler: {e}")
2025-12-07 17:15:09 +02:00
def stop_all_schedulers(self):
"""Stop all schedulers"""
logger.info("Stopping all schedulers...")
2025-12-07 17:15:09 +02:00
for scheduler in self.schedulers.values():
try:
scheduler.shutdown()
except Exception as e:
logger.warning(f"Error stopping scheduler: {e}")
2025-12-07 17:15:09 +02:00
self.schedulers.clear()
logger.info("All schedulers stopped")
2025-12-07 17:15:09 +02:00
# Implementation of autonomous functions - these integrate with the autonomous system
def _run_autonomous_for_server(self, guild_id: int, client: discord.Client):
"""Run autonomous behavior for a specific server - called by APScheduler"""
try:
# V2: Use the new context-aware autonomous system
from utils.autonomous import autonomous_tick
# Create an async task in the client's event loop
if client.loop and client.loop.is_running():
client.loop.create_task(autonomous_tick(guild_id))
logger.debug(f"[V2] Autonomous tick queued for server {guild_id}")
2025-12-07 17:15:09 +02:00
else:
logger.warning(f"Client loop not available for autonomous tick in server {guild_id}")
2025-12-07 17:15:09 +02:00
except Exception as e:
logger.error(f"Error in autonomous tick for server {guild_id}: {e}")
2025-12-07 17:15:09 +02:00
def _run_autonomous_reaction_for_server(self, guild_id: int, client: discord.Client):
"""Run autonomous reaction for a specific server - called by APScheduler"""
try:
# V2: Use the new context-aware reaction system
from utils.autonomous import autonomous_reaction_tick
# Create an async task in the client's event loop
if client.loop and client.loop.is_running():
client.loop.create_task(autonomous_reaction_tick(guild_id))
logger.debug(f"[V2] Autonomous reaction queued for server {guild_id}")
2025-12-07 17:15:09 +02:00
else:
logger.warning(f"Client loop not available for autonomous reaction in server {guild_id}")
2025-12-07 17:15:09 +02:00
except Exception as e:
logger.error(f"Error in autonomous reaction for server {guild_id}: {e}")
2025-12-07 17:15:09 +02:00
def _run_conversation_detection_for_server(self, guild_id: int, client: discord.Client):
"""Run conversation detection for a specific server - called by APScheduler"""
try:
from utils.autonomous import miku_detect_and_join_conversation_for_server
# Create an async task in the client's event loop
if client.loop and client.loop.is_running():
client.loop.create_task(miku_detect_and_join_conversation_for_server(guild_id))
logger.debug(f"Conversation detection queued for server {guild_id}")
2025-12-07 17:15:09 +02:00
else:
logger.warning(f"Client loop not available for conversation detection in server {guild_id}")
2025-12-07 17:15:09 +02:00
except Exception as e:
logger.error(f"Error in conversation detection for server {guild_id}: {e}")
2025-12-07 17:15:09 +02:00
def _send_monday_video_for_server(self, guild_id: int, client: discord.Client):
"""Send Monday video for a specific server - called by APScheduler"""
try:
from utils.scheduled import send_monday_video_for_server
# Create an async task in the client's event loop
if client.loop and client.loop.is_running():
client.loop.create_task(send_monday_video_for_server(guild_id))
logger.debug(f"Monday video queued for server {guild_id}")
2025-12-07 17:15:09 +02:00
else:
logger.warning(f"Client loop not available for Monday video in server {guild_id}")
2025-12-07 17:15:09 +02:00
except Exception as e:
logger.error(f"Error in Monday video for server {guild_id}: {e}")
2025-12-07 17:15:09 +02:00
def _schedule_random_bedtime_for_server(self, guild_id: int, client: discord.Client):
"""Schedule bedtime reminder for a specific server at a random time within the configured range"""
logger.info(f"Bedtime scheduler triggered for server {guild_id} at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
2025-12-07 17:15:09 +02:00
# Get server config to determine the random time range
server_config = self.servers.get(guild_id)
if not server_config:
logger.warning(f"No server config found for guild {guild_id}")
2025-12-07 17:15:09 +02:00
return
# Calculate random time within the bedtime range
start_minutes = server_config.bedtime_hour * 60 + server_config.bedtime_minute
end_minutes = server_config.bedtime_hour_end * 60 + server_config.bedtime_minute_end
logger.debug(f"Bedtime range calculation: {server_config.bedtime_hour:02d}:{server_config.bedtime_minute:02d} ({start_minutes} min) to {server_config.bedtime_hour_end:02d}:{server_config.bedtime_minute_end:02d} ({end_minutes} min)")
2025-12-07 17:15:09 +02:00
# Handle case where end time is next day (e.g., 23:30 to 00:30)
if end_minutes <= start_minutes:
end_minutes += 24 * 60 # Add 24 hours
logger.debug(f"Cross-midnight range detected, adjusted end to {end_minutes} minutes")
2025-12-07 17:15:09 +02:00
random_minutes = random.randint(start_minutes, end_minutes)
logger.debug(f"Random time selected: {random_minutes} minutes from midnight")
2025-12-07 17:15:09 +02:00
# Convert back to hours and minutes
random_hour = (random_minutes // 60) % 24
random_minute = random_minutes % 60
# Calculate delay until the random time
now = datetime.now()
target_time = now.replace(hour=random_hour, minute=random_minute, second=0, microsecond=0)
# If the target time has already passed today, schedule for tomorrow
if target_time <= now:
target_time += timedelta(days=1)
delay_seconds = (target_time - now).total_seconds()
logger.info(f"Random bedtime for server {server_config.guild_name}: {random_hour:02d}:{random_minute:02d} (in {delay_seconds/60:.1f} minutes)")
2025-12-07 17:15:09 +02:00
# Schedule the actual bedtime reminder
try:
from utils.scheduled import send_bedtime_reminder_for_server
def send_bedtime_delayed():
if client.loop and client.loop.is_running():
client.loop.create_task(send_bedtime_reminder_for_server(guild_id, client))
logger.info(f"Random bedtime reminder sent for server {guild_id}")
2025-12-07 17:15:09 +02:00
else:
logger.warning(f"Client loop not available for bedtime reminder in server {guild_id}")
2025-12-07 17:15:09 +02:00
# Use the scheduler to schedule the delayed bedtime reminder
scheduler = self.schedulers.get(guild_id)
if scheduler:
scheduler.add_job(
send_bedtime_delayed,
DateTrigger(run_date=target_time),
id=f"bedtime_reminder_{guild_id}_{int(target_time.timestamp())}"
)
logger.info(f"Bedtime reminder scheduled for server {guild_id} at {target_time.strftime('%Y-%m-%d %H:%M:%S')}")
2025-12-07 17:15:09 +02:00
else:
logger.warning(f"No scheduler found for server {guild_id}")
2025-12-07 17:15:09 +02:00
except Exception as e:
logger.error(f"Error scheduling bedtime reminder for server {guild_id}: {e}")
2025-12-07 17:15:09 +02:00
def _rotate_server_mood(self, guild_id: int, client: discord.Client):
"""Rotate mood for a specific server - called by APScheduler"""
try:
from utils.moods import rotate_server_mood
# Create an async task in the client's event loop
if client.loop and client.loop.is_running():
client.loop.create_task(rotate_server_mood(guild_id))
logger.debug(f"Mood rotation queued for server {guild_id}")
2025-12-07 17:15:09 +02:00
else:
logger.warning(f"Client loop not available for mood rotation in server {guild_id}")
2025-12-07 17:15:09 +02:00
except Exception as e:
logger.error(f"Error in mood rotation for server {guild_id}: {e}")
2025-12-07 17:15:09 +02:00
# Global instance
server_manager = ServerManager()