1a9c92b6db
Includes edge-node (FastAPI/MQTT/Discord voice), op25-container (SDR decoder), and icecast (audio streaming).
120 lines
4.1 KiB
Python
120 lines
4.1 KiB
Python
import asyncio
|
|
from typing import Optional
|
|
import discord
|
|
from discord.ext import commands
|
|
from app.config import settings
|
|
from app.internal.logger import logger
|
|
|
|
BOT_READY_TIMEOUT = 15 # seconds to wait for Discord bot to become ready
|
|
|
|
|
|
class RadioBot:
|
|
def __init__(self):
|
|
self._bot: Optional[commands.Bot] = None
|
|
self._voice_client: Optional[discord.VoiceClient] = None
|
|
self._task: Optional[asyncio.Task] = None
|
|
self._ready_event: Optional[asyncio.Event] = None
|
|
self._current_token: Optional[str] = None
|
|
self._icecast_url = (
|
|
f"http://{settings.icecast_host}:{settings.icecast_port}{settings.icecast_mount}"
|
|
)
|
|
|
|
async def join(self, guild_id: int, channel_id: int, token: str) -> bool:
|
|
# (Re)start the bot if the token changed or the bot isn't running
|
|
if self._current_token != token or not self._is_bot_running():
|
|
if not await self._start_bot(token):
|
|
return False
|
|
|
|
guild = self._bot.get_guild(guild_id)
|
|
if not guild:
|
|
logger.error(f"Guild {guild_id} not found — bot may not be a member.")
|
|
return False
|
|
|
|
channel = guild.get_channel(channel_id)
|
|
if not isinstance(channel, discord.VoiceChannel):
|
|
logger.error(f"Channel {channel_id} is not a voice channel.")
|
|
return False
|
|
|
|
try:
|
|
if self._voice_client and self._voice_client.is_connected():
|
|
await self._voice_client.disconnect(force=True)
|
|
self._voice_client = await channel.connect()
|
|
self._play_stream()
|
|
logger.info(f"Streaming to #{channel.name} in {guild.name}")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to join voice channel: {e}")
|
|
return False
|
|
|
|
async def leave(self) -> bool:
|
|
if self._voice_client and self._voice_client.is_connected():
|
|
try:
|
|
await self._voice_client.disconnect(force=True)
|
|
self._voice_client = None
|
|
logger.info("Disconnected from voice channel.")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to disconnect: {e}")
|
|
return False
|
|
|
|
async def stop(self):
|
|
await self.leave()
|
|
if self._task:
|
|
self._task.cancel()
|
|
if self._bot:
|
|
await self._bot.close()
|
|
self._bot = None
|
|
self._task = None
|
|
self._current_token = None
|
|
self._ready_event = None
|
|
|
|
def _play_stream(self):
|
|
if not self._voice_client:
|
|
return
|
|
source = discord.FFmpegPCMAudio(
|
|
self._icecast_url,
|
|
before_options="-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5",
|
|
)
|
|
self._voice_client.play(
|
|
discord.PCMVolumeTransformer(source, volume=1.0),
|
|
after=lambda e: logger.error(f"Stream ended unexpectedly: {e}") if e else None,
|
|
)
|
|
|
|
async def _start_bot(self, token: str) -> bool:
|
|
await self.stop() # clean up any previous instance
|
|
|
|
intents = discord.Intents.default()
|
|
intents.voice_states = True
|
|
self._bot = commands.Bot(command_prefix="!", intents=intents)
|
|
self._ready_event = asyncio.Event()
|
|
self._current_token = token
|
|
|
|
@self._bot.event
|
|
async def on_ready():
|
|
logger.info(f"Discord bot ready: {self._bot.user} ({self._bot.user.id})")
|
|
self._ready_event.set()
|
|
|
|
self._task = asyncio.create_task(self._bot.start(token))
|
|
|
|
try:
|
|
await asyncio.wait_for(self._ready_event.wait(), timeout=BOT_READY_TIMEOUT)
|
|
return True
|
|
except asyncio.TimeoutError:
|
|
logger.error("Timed out waiting for Discord bot to become ready.")
|
|
await self.stop()
|
|
return False
|
|
|
|
def _is_bot_running(self) -> bool:
|
|
return (
|
|
self._bot is not None
|
|
and self._task is not None
|
|
and not self._task.done()
|
|
)
|
|
|
|
@property
|
|
def is_connected(self) -> bool:
|
|
return self._voice_client is not None and self._voice_client.is_connected()
|
|
|
|
|
|
radio_bot = RadioBot()
|