- In on_ready(), set presence based on current mood (evil or normal) after all state is restored - When LLM-detected mood shift is applied, update presence immediately
640 lines
27 KiB
Python
640 lines
27 KiB
Python
import discord
|
|
import asyncio
|
|
import threading
|
|
import uvicorn
|
|
import logging
|
|
import sys
|
|
import random
|
|
import string
|
|
import signal
|
|
import atexit
|
|
from api import app
|
|
|
|
# Import new configuration system
|
|
from config import CONFIG, SECRETS, validate_config, print_config_summary
|
|
|
|
from server_manager import server_manager
|
|
from config_manager import config_manager
|
|
from utils.scheduled import (
|
|
send_monday_video
|
|
)
|
|
from utils.image_handling import (
|
|
process_media_in_message,
|
|
)
|
|
from utils.core import (
|
|
is_miku_addressed,
|
|
)
|
|
from utils.moods import (
|
|
detect_mood_shift
|
|
)
|
|
from utils.media import(
|
|
overlay_username_with_ffmpeg
|
|
)
|
|
from utils.llm import query_llama
|
|
from utils.autonomous import (
|
|
setup_autonomous_speaking,
|
|
load_last_sent_tweets,
|
|
# V2 imports
|
|
on_message_event,
|
|
on_presence_update as autonomous_presence_update,
|
|
on_member_join as autonomous_member_join,
|
|
initialize_v2_system
|
|
)
|
|
from utils.dm_logger import dm_logger
|
|
from utils.dm_interaction_analyzer import init_dm_analyzer
|
|
from utils.logger import get_logger
|
|
from utils.task_tracker import create_tracked_task
|
|
|
|
import globals
|
|
|
|
# Initialize bot logger
|
|
logger = get_logger('bot')
|
|
|
|
# Validate configuration on startup
|
|
is_valid, validation_errors = validate_config()
|
|
if not is_valid:
|
|
logger.error("❌ Configuration validation failed!")
|
|
for error in validation_errors:
|
|
logger.error(f" - {error}")
|
|
logger.error("Please check your .env file and restart.")
|
|
sys.exit(1)
|
|
|
|
# Print configuration summary for debugging
|
|
if CONFIG.autonomous.debug_mode:
|
|
print_config_summary()
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s: %(message)s",
|
|
handlers=[
|
|
logging.FileHandler("bot.log", mode='a', encoding='utf-8'),
|
|
logging.StreamHandler(sys.stdout) # Optional: see logs in stdout too
|
|
],
|
|
force=True # Override previous configs
|
|
)
|
|
|
|
# Reduce noise from discord voice receiving library
|
|
# CryptoErrors are routine packet decode failures (joins/leaves/key negotiation)
|
|
# RTCP packets are control packets sent every ~1s
|
|
# Both are harmless and just clutter logs
|
|
logging.getLogger('discord.ext.voice_recv.reader').setLevel(logging.CRITICAL) # Only show critical errors
|
|
|
|
@globals.client.event
|
|
async def on_ready():
|
|
logger.info(f'🎤 MikuBot connected as {globals.client.user}')
|
|
logger.info(f'💬 DM support enabled - users can message Miku directly!')
|
|
|
|
globals.BOT_USER = globals.client.user
|
|
|
|
# Intercept external library loggers (APScheduler, etc.)
|
|
from utils.logger import intercept_external_loggers
|
|
intercept_external_loggers()
|
|
|
|
# Restore evil mode state from previous session (if any)
|
|
from utils.evil_mode import restore_evil_mode_on_startup, restore_evil_cat_state
|
|
restore_evil_mode_on_startup()
|
|
|
|
# Restore Cat personality/model state (async — needs event loop running)
|
|
await restore_evil_cat_state()
|
|
|
|
# Restore bipolar mode state from previous session (if any)
|
|
from utils.bipolar_mode import restore_bipolar_mode_on_startup
|
|
restore_bipolar_mode_on_startup()
|
|
|
|
# Restore runtime settings (language, debug flags, etc.) from config_runtime.yaml
|
|
config_manager.restore_runtime_settings()
|
|
|
|
# Initialize DM interaction analyzer
|
|
if globals.OWNER_USER_ID and globals.OWNER_USER_ID != 0:
|
|
init_dm_analyzer(globals.OWNER_USER_ID)
|
|
logger.info(f"📊 DM Interaction Analyzer initialized for owner ID: {globals.OWNER_USER_ID}")
|
|
|
|
# Schedule daily DM analysis (runs at 2 AM every day)
|
|
from utils.scheduled import run_daily_dm_analysis
|
|
globals.scheduler.add_job(
|
|
run_daily_dm_analysis,
|
|
'cron',
|
|
hour=2,
|
|
minute=0,
|
|
id='daily_dm_analysis'
|
|
)
|
|
logger.info("⏰ Scheduled daily DM analysis at 2:00 AM")
|
|
else:
|
|
logger.warning("OWNER_USER_ID not set, DM analysis feature disabled")
|
|
|
|
# Setup autonomous speaking (now handled by server manager)
|
|
setup_autonomous_speaking()
|
|
load_last_sent_tweets()
|
|
|
|
# Initialize the V2 autonomous system
|
|
initialize_v2_system(globals.client)
|
|
|
|
# Initialize profile picture manager
|
|
from utils.profile_picture_manager import profile_picture_manager
|
|
await profile_picture_manager.initialize()
|
|
|
|
# Save current avatar as fallback
|
|
await profile_picture_manager.save_current_avatar_as_fallback()
|
|
|
|
# Set initial Discord presence based on current mood
|
|
try:
|
|
from utils.activities import update_bot_presence
|
|
if globals.EVIL_MODE:
|
|
await update_bot_presence(globals.EVIL_DM_MOOD, is_evil=True)
|
|
else:
|
|
await update_bot_presence(globals.DM_MOOD, is_evil=False)
|
|
except Exception as e:
|
|
logger.error(f"Failed to set initial presence: {e}")
|
|
|
|
# Start server-specific schedulers (includes DM mood rotation)
|
|
server_manager.start_all_schedulers(globals.client)
|
|
|
|
# Start the global scheduler for other tasks
|
|
globals.scheduler.start()
|
|
|
|
@globals.client.event
|
|
async def on_message(message):
|
|
if message.author == globals.client.user:
|
|
return
|
|
|
|
# Check for voice commands first (!miku join, !miku leave, !miku voice-status, !miku test, !miku say, !miku listen, !miku stop-listening)
|
|
if not isinstance(message.channel, discord.DMChannel) and message.content.strip().lower().startswith('!miku '):
|
|
from commands.voice import handle_voice_command
|
|
|
|
parts = message.content.strip().split()
|
|
if len(parts) >= 2:
|
|
cmd = parts[1].lower()
|
|
args = parts[2:] if len(parts) > 2 else []
|
|
|
|
if cmd in ['join', 'leave', 'voice-status', 'test', 'say', 'listen', 'stop-listening']:
|
|
await handle_voice_command(message, cmd, args)
|
|
return
|
|
|
|
# Check for UNO commands (!uno create, !uno join, !uno list, !uno quit, !uno help)
|
|
if message.content.strip().lower().startswith('!uno'):
|
|
from commands.uno import handle_uno_command
|
|
await handle_uno_command(message)
|
|
return
|
|
|
|
# Block all text responses when voice session is active
|
|
if globals.VOICE_SESSION_ACTIVE:
|
|
# Queue the message for later processing (optional)
|
|
if not hasattr(message, 'author') or message.author != globals.client.user:
|
|
globals.TEXT_MESSAGE_QUEUE.append({
|
|
'message': message,
|
|
'timestamp': message.created_at,
|
|
'channel_id': message.channel.id,
|
|
'content': message.content
|
|
})
|
|
logger.debug(f"Message queued during voice session: {message.content[:50]}...")
|
|
return # Don't process any messages during voice session
|
|
|
|
# Skip processing if a bipolar argument is in progress in this channel
|
|
if not isinstance(message.channel, discord.DMChannel):
|
|
from utils.bipolar_mode import is_argument_in_progress
|
|
if is_argument_in_progress(message.channel.id):
|
|
return
|
|
|
|
# Skip processing if a persona dialogue is in progress in this channel
|
|
from utils.persona_dialogue import is_persona_dialogue_active
|
|
if is_persona_dialogue_active(message.channel.id):
|
|
return
|
|
|
|
if message.content.strip().lower() == "miku, rape this nigga balls" and message.reference:
|
|
async with message.channel.typing():
|
|
# Get replied-to user
|
|
try:
|
|
replied_msg = await message.channel.fetch_message(message.reference.message_id)
|
|
target_username = replied_msg.author.display_name
|
|
|
|
# Prepare video
|
|
base_video = "MikuMikuBeam.mp4"
|
|
output_video = f"/tmp/video_{''.join(random.choices(string.ascii_letters, k=5))}.mp4"
|
|
|
|
await overlay_username_with_ffmpeg(base_video, output_video, target_username)
|
|
|
|
caption = f"Here you go, @{target_username}! 🌟"
|
|
#await message.channel.send(content=caption, file=discord.File(output_video))
|
|
await replied_msg.reply(file=discord.File(output_video))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing video: {e}")
|
|
await message.channel.send("Sorry, something went wrong while generating the video.")
|
|
return
|
|
|
|
text = message.content.strip()
|
|
|
|
# Check if this is a DM
|
|
is_dm = message.guild is None
|
|
|
|
# Check if message is addressed to Miku (needed to decide whether to track for autonomous)
|
|
miku_addressed = await is_miku_addressed(message)
|
|
|
|
if is_dm:
|
|
logger.info(f"💌 DM from {message.author.display_name}: {message.content[:50]}{'...' if len(message.content) > 50 else ''}")
|
|
|
|
# Check if user is blocked
|
|
if dm_logger.is_user_blocked(message.author.id):
|
|
logger.info(f"🚫 Blocked user {message.author.display_name} ({message.author.id}) tried to send DM - ignoring")
|
|
return
|
|
|
|
# Log the user's DM message
|
|
dm_logger.log_user_message(message.author, message, is_bot_message=False)
|
|
|
|
if miku_addressed:
|
|
|
|
prompt = text # No cleanup — keep it raw
|
|
user_id = str(message.author.id)
|
|
|
|
# If user is replying to a specific message, add context marker
|
|
if message.reference:
|
|
try:
|
|
replied_msg = await message.channel.fetch_message(message.reference.message_id)
|
|
# Only add context if replying to Miku's message
|
|
if replied_msg.author == globals.client.user:
|
|
# Truncate the replied message to keep prompt manageable
|
|
replied_content = replied_msg.content[:200] + "..." if len(replied_msg.content) > 200 else replied_msg.content
|
|
# Add reply context marker to the prompt
|
|
prompt = f'[Replying to your message: "{replied_content}"] {prompt}'
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch replied message for context: {e}")
|
|
|
|
async with message.channel.typing():
|
|
# Check if vision model is blocked (voice session active)
|
|
if message.attachments and globals.VISION_MODEL_BLOCKED:
|
|
await message.channel.send(
|
|
"🎤 I can't look at images or videos right now, I'm talking in voice chat! "
|
|
"Send it again after I leave the voice channel."
|
|
)
|
|
return
|
|
|
|
# Dispatch media processing (images, videos, GIFs, embeds)
|
|
# to utils/image_handling.process_media_in_message()
|
|
guild_id = message.guild.id if message.guild else None
|
|
if await process_media_in_message(message, prompt, is_dm, guild_id):
|
|
return
|
|
|
|
# Check if this is an image generation request
|
|
from utils.image_generation import detect_image_request, handle_image_generation_request
|
|
is_image_request, image_prompt = await detect_image_request(prompt)
|
|
|
|
if is_image_request and image_prompt:
|
|
logger.info(f"🎨 Image generation request detected: '{image_prompt}' from {message.author.display_name}")
|
|
|
|
# Block image generation during voice sessions
|
|
if globals.IMAGE_GENERATION_BLOCKED:
|
|
await message.channel.send(globals.IMAGE_GENERATION_BLOCK_MESSAGE)
|
|
await message.add_reaction('🎤')
|
|
logger.info("🚫 Image generation blocked - voice session active")
|
|
return
|
|
|
|
# Handle the image generation workflow
|
|
success = await handle_image_generation_request(message, image_prompt)
|
|
if success:
|
|
return # Image generation completed successfully
|
|
|
|
# If image generation failed, fall back to normal response
|
|
logger.warning(f"Image generation failed, falling back to normal response")
|
|
|
|
# If message is just a prompt, no image
|
|
# For DMs, pass None as guild_id to use DM mood
|
|
guild_id = message.guild.id if message.guild else None
|
|
response_type = "dm_response" if is_dm else "server_response"
|
|
author_name = message.author.display_name
|
|
|
|
# Phase 3: Try Cheshire Cat pipeline first (memory-augmented response)
|
|
# Falls back to query_llama if Cat is unavailable or disabled
|
|
response = None
|
|
if globals.USE_CHESHIRE_CAT:
|
|
try:
|
|
from utils.cat_client import cat_adapter
|
|
current_mood = globals.DM_MOOD
|
|
if guild_id:
|
|
try:
|
|
from server_manager import server_manager
|
|
sc = server_manager.get_server_config(guild_id)
|
|
if sc:
|
|
current_mood = sc.current_mood_name
|
|
except Exception:
|
|
pass
|
|
|
|
cat_result = await cat_adapter.query(
|
|
text=prompt,
|
|
user_id=str(message.author.id),
|
|
guild_id=str(guild_id) if guild_id else None,
|
|
author_name=author_name,
|
|
mood=current_mood,
|
|
response_type=response_type,
|
|
)
|
|
if cat_result:
|
|
response, cat_full_prompt = cat_result
|
|
effective_mood = current_mood
|
|
if globals.EVIL_MODE:
|
|
effective_mood = f"EVIL:{getattr(globals, 'EVIL_DM_MOOD', 'evil_neutral')}"
|
|
logger.info(f"🐱 Cat response for {author_name} (mood: {effective_mood})")
|
|
# Track Cat interaction for Web UI Last Prompt view
|
|
import datetime
|
|
globals.LAST_CAT_INTERACTION = {
|
|
"full_prompt": cat_full_prompt,
|
|
"response": response[:500] if response else "",
|
|
"user": author_name,
|
|
"mood": effective_mood,
|
|
"timestamp": datetime.datetime.now().isoformat(),
|
|
}
|
|
except Exception as e:
|
|
logger.warning(f"🐱 Cat pipeline error, falling back to query_llama: {e}")
|
|
response = None
|
|
|
|
# Fallback to direct LLM query if Cat didn't respond
|
|
if not response:
|
|
response = await query_llama(
|
|
prompt,
|
|
user_id=str(message.author.id),
|
|
guild_id=guild_id,
|
|
response_type=response_type,
|
|
author_name=author_name
|
|
)
|
|
|
|
from utils.image_handling import _send_log_bipolar
|
|
response_message = await _send_log_bipolar(message, response, is_dm)
|
|
|
|
# For server messages, do server-specific mood detection
|
|
if not is_dm and message.guild:
|
|
try:
|
|
from server_manager import server_manager
|
|
server_config = server_manager.get_server_config(message.guild.id)
|
|
if server_config:
|
|
# Create server context for mood detection
|
|
server_context = {
|
|
'current_mood_name': server_config.current_mood_name,
|
|
'current_mood_description': server_config.current_mood_description,
|
|
'is_sleeping': server_config.is_sleeping
|
|
}
|
|
|
|
detected = detect_mood_shift(response, server_context)
|
|
if detected and detected != server_config.current_mood_name:
|
|
logger.info(f"🔄 Auto mood detection for server {message.guild.name}: {server_config.current_mood_name} -> {detected}")
|
|
|
|
# Block direct transitions to asleep unless from sleepy
|
|
if detected == "asleep" and server_config.current_mood_name != "sleepy":
|
|
logger.warning("Ignoring asleep mood; server wasn't sleepy before.")
|
|
else:
|
|
# Update server mood
|
|
server_manager.set_server_mood(message.guild.id, detected)
|
|
|
|
# Update nickname for this server
|
|
from utils.moods import update_server_nickname
|
|
globals.client.loop.create_task(update_server_nickname(message.guild.id))
|
|
|
|
# Update Discord presence to match new mood
|
|
try:
|
|
from utils.activities import update_bot_presence
|
|
await update_bot_presence(detected, is_evil=False)
|
|
except Exception as e:
|
|
logger.error(f"Failed to update presence after mood detection: {e}")
|
|
|
|
logger.info(f"🔄 Server mood auto-updated to: {detected}")
|
|
|
|
if detected == "asleep":
|
|
server_manager.set_server_sleep_state(message.guild.id, True)
|
|
server_manager.schedule_wakeup_task(message.guild.id, delay_seconds=3600)
|
|
else:
|
|
logger.error(f"No server config found for guild {message.guild.id}, skipping mood detection")
|
|
except Exception as e:
|
|
logger.error(f"Error in server mood detection: {e}")
|
|
elif is_dm:
|
|
logger.debug("DM message - no mood detection (DM mood only changes via auto-rotation)")
|
|
|
|
# V2: Track message for autonomous engine (non-blocking, no LLM calls)
|
|
# IMPORTANT: Only call this if the message was NOT addressed to Miku
|
|
# This prevents autonomous actions from firing when the user is directly talking to Miku
|
|
if not miku_addressed:
|
|
on_message_event(message)
|
|
|
|
# Note: Autonomous reactions are now handled by V2 system via on_message_event()
|
|
|
|
# Manual Monday test command (only for server messages)
|
|
if not is_dm and message.content.lower().strip() == "!monday":
|
|
await send_monday_video()
|
|
#await message.channel.send("✅ Monday message sent (or attempted). Check logs.")
|
|
return
|
|
|
|
@globals.client.event
|
|
async def on_raw_reaction_add(payload):
|
|
"""Handle reactions added to messages (including bot's own reactions and uncached messages)"""
|
|
# Check if this is a DM
|
|
if payload.guild_id is not None:
|
|
return # Only handle DM reactions
|
|
|
|
# Get the channel
|
|
channel = await globals.client.fetch_channel(payload.channel_id)
|
|
if not isinstance(channel, discord.DMChannel):
|
|
return
|
|
|
|
# Get the user who reacted
|
|
user = await globals.client.fetch_user(payload.user_id)
|
|
|
|
# Get the DM partner (the person DMing the bot, not the bot itself)
|
|
# For DMs, we want to log under the user's ID, not the bot's
|
|
if user.id == globals.client.user.id:
|
|
# Bot reacted - find the other user in the DM
|
|
message = await channel.fetch_message(payload.message_id)
|
|
dm_user_id = message.author.id if message.author.id != globals.client.user.id else channel.recipient.id
|
|
is_bot_reactor = True
|
|
else:
|
|
# User reacted
|
|
dm_user_id = user.id
|
|
is_bot_reactor = False
|
|
|
|
# Get emoji string
|
|
emoji_str = str(payload.emoji)
|
|
|
|
# Log the reaction
|
|
await dm_logger.log_reaction_add(
|
|
user_id=dm_user_id,
|
|
message_id=payload.message_id,
|
|
emoji=emoji_str,
|
|
reactor_id=user.id,
|
|
reactor_name=user.display_name or user.name,
|
|
is_bot_reactor=is_bot_reactor
|
|
)
|
|
|
|
reactor_type = "🤖 Miku" if is_bot_reactor else f"👤 {user.display_name}"
|
|
logger.debug(f"DM reaction added: {emoji_str} by {reactor_type} on message {payload.message_id}")
|
|
|
|
@globals.client.event
|
|
async def on_raw_reaction_remove(payload):
|
|
"""Handle reactions removed from messages (including bot's own reactions and uncached messages)"""
|
|
# Check if this is a DM
|
|
if payload.guild_id is not None:
|
|
return # Only handle DM reactions
|
|
|
|
# Get the channel
|
|
channel = await globals.client.fetch_channel(payload.channel_id)
|
|
if not isinstance(channel, discord.DMChannel):
|
|
return
|
|
|
|
# Get the user who removed the reaction
|
|
user = await globals.client.fetch_user(payload.user_id)
|
|
|
|
# Get the DM partner (the person DMing the bot, not the bot itself)
|
|
if user.id == globals.client.user.id:
|
|
# Bot removed reaction - find the other user in the DM
|
|
message = await channel.fetch_message(payload.message_id)
|
|
dm_user_id = message.author.id if message.author.id != globals.client.user.id else channel.recipient.id
|
|
else:
|
|
# User removed reaction
|
|
dm_user_id = user.id
|
|
|
|
# Get emoji string
|
|
emoji_str = str(payload.emoji)
|
|
|
|
# Log the reaction removal
|
|
await dm_logger.log_reaction_remove(
|
|
user_id=dm_user_id,
|
|
message_id=payload.message_id,
|
|
emoji=emoji_str,
|
|
reactor_id=user.id
|
|
)
|
|
|
|
reactor_type = "🤖 Miku" if user.id == globals.client.user.id else f"👤 {user.display_name}"
|
|
logger.debug(f"DM reaction removed: {emoji_str} by {reactor_type} from message {payload.message_id}")
|
|
|
|
@globals.client.event
|
|
async def on_presence_update(before, after):
|
|
"""Track user presence changes for autonomous V2 system"""
|
|
# Discord.py passes before/after Member objects with different states
|
|
# We pass the 'after' member and both states for comparison
|
|
autonomous_presence_update(after, before, after)
|
|
|
|
@globals.client.event
|
|
async def on_member_join(member):
|
|
"""Track member joins for autonomous V2 system"""
|
|
autonomous_member_join(member)
|
|
|
|
@globals.client.event
|
|
async def on_voice_state_update(member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
|
|
"""Track voice channel join/leave for voice call management."""
|
|
from utils.voice_manager import VoiceSessionManager
|
|
|
|
session_manager = VoiceSessionManager()
|
|
if not session_manager.active_session:
|
|
return
|
|
|
|
# Check if this is our voice channel
|
|
if before.channel != session_manager.active_session.voice_channel and \
|
|
after.channel != session_manager.active_session.voice_channel:
|
|
return
|
|
|
|
# User joined our voice channel
|
|
if before.channel != after.channel and after.channel == session_manager.active_session.voice_channel:
|
|
logger.info(f"👤 {member.name} joined voice channel")
|
|
await session_manager.active_session.on_user_join(member.id)
|
|
|
|
# Auto-start listening if this is a voice call
|
|
if session_manager.active_session.call_user_id == member.id:
|
|
await session_manager.active_session.start_listening(member)
|
|
|
|
# User left our voice channel
|
|
elif before.channel == session_manager.active_session.voice_channel and \
|
|
after.channel != before.channel:
|
|
logger.info(f"👤 {member.name} left voice channel")
|
|
await session_manager.active_session.on_user_leave(member.id)
|
|
|
|
# Stop listening to this user
|
|
await session_manager.active_session.stop_listening(member.id)
|
|
|
|
def start_api():
|
|
# Set log_level to "critical" to silence uvicorn's access logs
|
|
# Our custom api.requests middleware handles HTTP logging with better formatting and filtering
|
|
uvicorn.run(app, host="0.0.0.0", port=3939, log_level="critical")
|
|
|
|
def save_autonomous_state():
|
|
"""Save autonomous context on shutdown"""
|
|
try:
|
|
from utils.autonomous import autonomous_engine
|
|
autonomous_engine.save_context()
|
|
logger.info("💾 Saved autonomous context on shutdown")
|
|
except Exception as e:
|
|
logger.error(f"Failed to save autonomous context on shutdown: {e}")
|
|
|
|
async def graceful_shutdown():
|
|
"""
|
|
Perform a full async cleanup before the bot exits.
|
|
|
|
Shutdown sequence:
|
|
1. End active voice sessions (disconnect, release GPU locks)
|
|
2. Save autonomous engine state
|
|
3. Stop the APScheduler
|
|
4. Cancel all tracked background tasks
|
|
5. Close the Discord gateway connection
|
|
"""
|
|
logger.warning("🛑 Graceful shutdown initiated...")
|
|
|
|
# 1. End active voice session (cleans up audio, STT, GPU locks, etc.)
|
|
try:
|
|
from utils.voice_manager import VoiceSessionManager
|
|
session_mgr = VoiceSessionManager()
|
|
if session_mgr.active_session:
|
|
logger.info("🎙️ Ending active voice session...")
|
|
await session_mgr.end_session()
|
|
logger.info("✓ Voice session ended")
|
|
except Exception as e:
|
|
logger.error(f"Error ending voice session during shutdown: {e}")
|
|
|
|
# 2. Persist autonomous engine state
|
|
save_autonomous_state()
|
|
|
|
# 3. Shut down the APScheduler
|
|
try:
|
|
if globals.scheduler.running:
|
|
globals.scheduler.shutdown(wait=False)
|
|
logger.info("✓ Scheduler stopped")
|
|
except Exception as e:
|
|
logger.error(f"Error stopping scheduler: {e}")
|
|
|
|
# 4. Cancel all tracked background tasks
|
|
try:
|
|
from utils.task_tracker import _active_tasks
|
|
pending = [t for t in _active_tasks if not t.done()]
|
|
if pending:
|
|
logger.info(f"Cancelling {len(pending)} background tasks...")
|
|
for t in pending:
|
|
t.cancel()
|
|
await asyncio.gather(*pending, return_exceptions=True)
|
|
logger.info("✓ Background tasks cancelled")
|
|
except Exception as e:
|
|
logger.error(f"Error cancelling background tasks: {e}")
|
|
|
|
# 5. Close the Discord gateway connection
|
|
try:
|
|
if not globals.client.is_closed():
|
|
await globals.client.close()
|
|
logger.info("✓ Discord client closed")
|
|
except Exception as e:
|
|
logger.error(f"Error closing Discord client: {e}")
|
|
|
|
logger.warning("🛑 Graceful shutdown complete")
|
|
|
|
def _handle_shutdown_signal(sig, _frame):
|
|
"""Schedule the async shutdown from a sync signal handler."""
|
|
sig_name = signal.Signals(sig).name
|
|
logger.warning(f"Received {sig_name}, scheduling graceful shutdown...")
|
|
# Schedule the coroutine on the running event loop
|
|
loop = asyncio.get_event_loop()
|
|
if loop.is_running():
|
|
loop.create_task(graceful_shutdown())
|
|
else:
|
|
# Fallback: just save state synchronously
|
|
save_autonomous_state()
|
|
|
|
# Register signal handlers (async-aware)
|
|
signal.signal(signal.SIGTERM, _handle_shutdown_signal)
|
|
signal.signal(signal.SIGINT, _handle_shutdown_signal)
|
|
|
|
# Keep atexit as a last-resort sync fallback
|
|
atexit.register(save_autonomous_state)
|
|
|
|
threading.Thread(target=start_api, daemon=True).start()
|
|
globals.client.run(globals.DISCORD_BOT_TOKEN)
|