from typing import Optional from collections import defaultdict import asyncio import discord from discord.ext import commands as cmds from discord.ext.commands.errors import CheckFailure from discord import app_commands as appcmds from meta import LionCog, LionBot, LionContext from meta.logger import log_wrap from meta.sharding import THIS_SHARD from meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel from utils.lib import utc_now from utils.ratelimits import limit_concurrency from meta.sockets import Channel, register_channel from wards import low_management_ward from . import babel, logger from .data import TimerData from .lib import TimerRole from .settings import TimerSettings from .settingui import TimerConfigUI from .timer import Timer from .options import TimerOptions from .ui.config import TimerOptionsUI _p = babel._p _param_options = { 'focus_length': (TimerOptions.FocusLength, TimerRole.MANAGER), 'break_length': (TimerOptions.BreakLength, TimerRole.MANAGER), 'notification_channel': (TimerOptions.NotificationChannel, TimerRole.ADMIN), 'inactivity_threshold': (TimerOptions.InactivityThreshold, TimerRole.OWNER), 'manager_role': (TimerOptions.ManagerRole, TimerRole.ADMIN), 'voice_alerts': (TimerOptions.VoiceAlerts, TimerRole.OWNER), 'name': (TimerOptions.BaseName, TimerRole.OWNER), 'channel_name': (TimerOptions.ChannelFormat, TimerRole.OWNER), } class TimerChannel(Channel): name = 'Timer' def __init__(self, cog: 'TimerCog', **kwargs): super().__init__(**kwargs) self.cog = cog self.channelid = 1261999440160624734 self.goal = 12 async def on_connection(self, websocket, event): await super().on_connection(websocket, event) await self.send_set( **await self.get_args_for(self.channelid), goal=self.goal, websocket=websocket, ) async def send_updates(self): await self.send_set( **await self.get_args_for(self.channelid), goal=self.goal, ) async def get_args_for(self, channelid): timer = self.cog.get_channel_timer(channelid) if timer is None: raise ValueError(f"Timer {channelid} doesn't exist.") return { 'start_at': timer.data.last_started, 'focus_length': timer.data.focus_length, 'break_length': timer.data.break_length, } async def send_set(self, start_at, focus_length, break_length, goal=12, websocket=None): await self.send_event({ 'type': "DO", 'method': 'setTimer', 'args': { 'start_at': start_at.isoformat(), 'focus_length': focus_length, 'break_length': break_length, 'block_goal': goal, } }, websocket=websocket) class TimerCog(LionCog): def __init__(self, bot: LionBot): self.bot = bot self.data = bot.db.load_registry(TimerData()) self.settings = TimerSettings() self.monitor = ComponentMonitor('TimerCog', self._monitor) self.channel = TimerChannel(self) register_channel(self.channel.name, self.channel) self.timer_options = TimerOptions() self.ready = False self.timers: dict[int, dict[int, Timer]] = defaultdict(dict) async def _monitor(self): timers = [timer for tguild in self.timers.values() for timer in tguild.values()] state = ( "" ) data = dict( loaded=len(timers), guilds=len(set(timer.data.guildid for timer in timers)), members=sum(len(timer.members) for timer in timers), running=sum(1 for timer in timers if timer.running), launched=sum(1 for timer in timers if timer._run_task and not timer._run_task.done()), looping=sum(1 for timer in timers if timer._loop_task and not timer._loop_task.done()), locked=sum(1 for timer in timers if timer._lock.locked()), voice_locked=sum(1 for timer in timers if timer.voice_lock.locked()), ) if not self.ready: level = StatusLevel.STARTING info = f"(STARTING) Not ready. {state}" else: level = StatusLevel.OKAY info = f"(OK) Ready. {state}" return ComponentStatus(level, info, info, data) async def cog_load(self): self.bot.system_monitor.add_component(self.monitor) await self.data.init() self.bot.core.guild_config.register_model_setting(self.settings.PomodoroChannel) configcog = self.bot.get_cog('ConfigCog') self.crossload_group(self.configure_group, configcog.config_group) if self.bot.is_ready(): await self.initialise() async def cog_unload(self): """ Detach TimerCog and unload components. Clears caches and stops run-tasks for each active timer. Does not exist until all timers have completed background tasks. """ timers = [timer for tguild in self.timers.values() for timer in tguild.values()] self.timers.clear() if timers: await self._unload_timers(timers) async def cog_check(self, ctx: LionContext): if not self.ready: raise CheckFailure( self.bot.translator.t(_p( 'cmd_check:ready|failed', "I am currently restarting! " "The Pomodoro timers will be unavailable until I have restarted. " "Thank you for your patience!" )) ) else: return True @log_wrap(action='Unload Timers') async def _unload_timers(self, timers: list[Timer]): """ Unload all active timers. """ tasks = [asyncio.create_task(timer.unload()) for timer in timers] for timer, task in zip(timers, tasks): try: await task except Exception: logger.exception( f"Unexpected exception while unloading timer {timer!r}" ) async def _load_timers(self, timer_data: list[TimerData.Timer]): """ Factored method to load a list of timers from data rows. """ guildids = set() to_delete = [] to_create = [] to_unload = [] for row in timer_data: channel = self.bot.get_channel(row.channelid) if not channel: to_delete.append(row.channelid) else: guildids.add(row.guildid) to_create.append(row) if row.guildid in self.timers: if row.channelid in self.timers[row.guildid]: to_unload.append(self.timers[row.guildid].pop(row.channelid)) if to_unload: await self._unload_timers(to_unload) if guildids: lguilds = await self.bot.core.lions.fetch_guilds(*guildids) else: lguilds = [] now = utc_now() to_launch = [] to_update = [] timer_reg = defaultdict(dict) for row in to_create: timer = Timer(self.bot, row, lguilds[row.guildid]) if timer.running: to_launch.append(timer) else: to_update.append(timer) timer_reg[row.guildid][row.channelid] = timer timer.last_seen = {member.id: now for member in timer.members} # Delete non-existent timers if to_delete: await self.data.Timer.table.delete_where(channelid=to_delete) idstr = ', '.join(map(str, to_delete)) logger.info( f"Destroyed {len(to_delete)} timers with missing voice channels: {idstr}" ) # Re-launch and update running timers for timer in to_launch: timer.launch() coros = [timer.update_status_card() for timer in to_launch] if coros: i = 0 async for task in limit_concurrency(coros, 10): try: await task except discord.HTTPException: timer = to_launch[i] logger.warning( f"Unhandled discord exception while updating timer status for {timer!r}", exc_info=True ) except Exception: timer = to_launch[i] logger.exception( f"Unexpected exception while updating timer status for {timer!r}", exc_info=True ) i += 1 logger.info( f"Updated and launched {len(to_launch)} running timers." ) # Update stopped timers coros = [timer.update_status_card(render=False) for timer in to_update] if coros: i = 0 async for task in limit_concurrency(coros, 10): try: await task except discord.HTTPException: timer = to_update[i] logger.warning( f"Unhandled discord exception while updating timer status for {timer!r}", exc_info=True ) except Exception: timer = to_update[i] logger.exception( f"Unexpected exception while updating timer status for {timer!r}", exc_info=True ) i += 1 logger.info( f"Updated {len(to_update)} stopped timers." ) # Update timer registry for gid, gtimers in timer_reg.items(): self.timers[gid].update(gtimers) @LionCog.listener('on_ready') @log_wrap(action='Init Timers') async def initialise(self): """ Restore timers. """ self.ready = False self.timers = defaultdict(dict) if self.timers: timers = [timer for tguild in self.timers.values() for timer in tguild.values()] await self._unload_timers(timers) self.timers.clear() # Fetch timers in guilds on this shard guildids = [guild.id for guild in self.bot.guilds] timer_data = await self.data.Timer.fetch_where(guildid=guildids) await self._load_timers(timer_data) # Ready to handle events self.ready = True logger.info("Timer system ready to process events.") # ----- Event Handlers ----- @LionCog.listener('on_voice_state_update') @log_wrap(action='Timer Voice Events') async def timer_voice_events(self, member, before, after): if not self.ready: # Trust initialise to trigger update status return if member.bot: return # If a member is leaving or joining a running timer, trigger a status update if before.channel != after.channel: leaving = self.get_channel_timer(before.channel.id) if before.channel else None joining = self.get_channel_timer(after.channel.id) if after.channel else None tasks = [] if leaving is not None: tasks.append(asyncio.create_task(leaving.update_status_card())) leaving.last_seen.pop(member.id, None) if joining is not None: joining.last_seen[member.id] = utc_now() if not joining.running and joining.auto_restart: tasks.append(asyncio.create_task(joining.start())) else: tasks.append(asyncio.create_task(joining.update_status_card())) if tasks: try: await asyncio.gather(*tasks) except Exception: logger.exception( "Exception occurred while handling timer voice event. " f"Leaving: {leaving!r} " f"Joining: {joining!r}" ) @LionCog.listener('on_guild_remove') @log_wrap(action='Unload Guild Timers') async def _unload_guild_timers(self, guild: discord.Guild): """ When we leave a guild, perform an unload for all timers in the Guild. """ if not self.ready: # Trust initialiser to ignore the guild return timers = self.timers.pop(guild.id, {}) tasks = [] for timer in timers.values(): tasks.append(asyncio.create_task(timer.unload())) if tasks: try: await asyncio.gather(*tasks) except Exception: logger.warning( "Exception occurred while unloading timers for removed guild.", exc_info=True ) logger.info( f"Unloaded {len(timers)} from removed guild ." ) @LionCog.listener('on_guild_join') @log_wrap(action='Load Guild Timers') async def _load_guild_timers(self, guild: discord.Guild): """ When we join a guild, reload any saved timers for this guild. """ timer_data = await self.data.Timer.fetch_where(guildid=guild.id) if timer_data: await self._load_timers(timer_data) @LionCog.listener('on_guild_channel_delete') @log_wrap(action='Destroy Channel Timer') async def _destroy_channel_timer(self, channel: discord.abc.GuildChannel): """ If a voice channel with a timer was deleted, destroy the timer. """ timer = self.get_channel_timer(channel.id) if timer is not None: await timer.destroy(reason="Voice Channel Deleted") @LionCog.listener('on_guildset_pomodoro_channel') @log_wrap(action='Update Pomodoro Channels') async def _update_pomodoro_channels(self, guildid: int, setting: TimerSettings.PomodoroChannel): """ Request a send_status for all guild timers which need to move channel. """ timers = self.get_guild_timers(guildid).values() tasks = [] for timer in timers: current_channel = timer.notification_channel current_hook = timer._hook if current_channel and (not current_hook or current_hook.channelid != current_channel.id): tasks.append(asyncio.create_task(timer.send_status())) if tasks: try: await asyncio.gather(*tasks) except Exception: logger.warning( "Exception occurred which refreshing status for timers with new notification_channel.", exc_info=True ) # ----- Timer API ----- def get_guild_timers(self, guildid: int) -> dict[int, Timer]: """ Get all timers in the given guild as a map channelid -> Timer. """ return self.timers[guildid] def get_channel_timer(self, channelid: int) -> Optional[Timer]: """ Get the timer bound to the given channel, or None if it does not exist. """ channel = self.bot.get_channel(channelid) if channel: return self.timers[channel.guild.id].get(channelid, None) async def create_timer(self, **kwargs): timer_data = await self.data.Timer.create(**kwargs) lguild = await self.bot.core.lions.fetch_guild(timer_data.guildid) timer = Timer(self.bot, timer_data, lguild) self.timers[timer_data.guildid][timer_data.channelid] = timer return timer async def destroy_timer(self, timer: Timer, **kwargs): """ Destroys the provided timer and removes it from the registry. """ self.timers[timer.data.guildid].pop(timer.data.channelid, None) await timer.destroy(**kwargs) # ----- Timer Commands ----- # -- User Display Commands -- @cmds.hybrid_command( name=_p('cmd:timer', "timer"), description=_p('cmd:timer|desc', "Show your current (or selected) pomodoro timer.") ) @appcmds.rename( channel=_p('cmd:timer|param:channel', "timer_channel") ) @appcmds.describe( channel=_p( 'cmd:timer|param:channel|desc', "Select a timer to display (by selecting the timer voice channel)" ) ) @cmds.guild_only() async def cmd_timer(self, ctx: LionContext, channel: Optional[discord.VoiceChannel] = None): t = self.bot.translator.t if not ctx.guild: return if not ctx.interaction: return timers: list[Timer] = list(self.get_guild_timers(ctx.guild.id).values()) error: Optional[discord.Embed] = None if not timers: # Guild has no timers error = discord.Embed( colour=discord.Colour.brand_red(), description=t(_p( 'cmd:timer|error:no_timers|desc', "**This server has no timers set up!**\n" "Ask an admin to set up and configure a timer with {create_cmd} first, " "or rent a private room with {room_cmd} and create one yourself!" )).format(create_cmd=self.bot.core.mention_cmd('pomodoro create'), room_cmd=self.bot.core.mention_cmd('rooms rent')) ) elif channel is None: if ctx.author.voice and ctx.author.voice.channel: channel = ctx.author.voice.channel else: error = discord.Embed( colour=discord.Colour.brand_red(), description=t(_p( 'cmd:timer|error:no_channel|desc', "**I don't know what timer to show you.**\n" "No channel selected and you are not in a voice channel! " "Use {timers_cmd} to list the available timers in this server." )).format(timers_cmd=self.bot.core.mention_cmd('timers')) ) if channel is not None: timer = self.get_channel_timer(channel.id) if timer is None: error = discord.Embed( colour=discord.Colour.brand_red(), description=t(_p( 'cmd:timer|error:no_timer_in_channel', "The channel {channel} is not a pomodoro timer room!\n" "Use {timers_cmd} to list the available timers in this server." )).format( channel=channel.mention, timers_cmd=self.bot.core.mention_cmd('timers') ) ) else: # Display the timer status ephemerally await ctx.interaction.response.defer(thinking=True, ephemeral=True) status = await timer.current_status(with_notify=False, with_warnings=False) await ctx.interaction.edit_original_response(**status.edit_args) if error is not None: await ctx.reply(embed=error, ephemeral=True) @cmds.hybrid_command( name=_p('cmd:timers', "timers"), description=_p('cmd:timers|desc', "List the available pomodoro timer rooms.") ) @cmds.guild_only() async def cmd_timers(self, ctx: LionContext): t = self.bot.translator.t if not ctx.guild: return if not ctx.interaction: return timers = list(self.get_guild_timers(ctx.guild.id).values()) # Extra filter here to exclude owned timers, but include ones the author is a member of visible_timers = [ timer for timer in timers if timer.channel and timer.channel.permissions_for(ctx.author).connect and (not timer.owned or (ctx.author in timer.channel.overwrites)) ] if not timers: # No timers in the guild embed = discord.Embed( colour=discord.Colour.brand_red(), description=t(_p( 'cmd:timer|error:no_timers|desc', "**This server has no timers set up!**\n" "Ask an admin to set up and configure a timer with {create_cmd} first, " "or rent a private room with {room_cmd} and create one yourself!" )).format(create_cmd=self.bot.core.mention_cmd('pomodoro create'), room_cmd=self.bot.core.mention_cmd('rooms rent')) ) await ctx.reply(embed=embed, ephemeral=True) elif not visible_timers: # Timers exist, but the member can't see any embed = discord.Embed( colour=discord.Colour.brand_red(), description=t(_p( 'cmd:timer|error:no_visible_timers|desc', "**There are no available pomodoro timers!**\n" "Ask an admin to set up a new timer with {create_cmd}, " "or rent a private room with {room_cmd} and create one yourself!" )).format(create_cmd=self.bot.core.mention_cmd('pomodoro create'), room_cmd=self.bot.core.mention_cmd('rooms rent')) ) await ctx.reply(embed=embed, ephemeral=True) else: # Timers exist and are visible! embed = discord.Embed( colour=discord.Colour.orange(), title=t(_p( 'cmd:timers|embed:timer_list|title', "Pomodoro Timer Rooms in **{guild}**" )).format(guild=ctx.guild.name), ) for timer in visible_timers: stage = timer.current_stage if stage is None: if timer.auto_restart: lazy_status = _p( 'cmd:timers|status:stopped_auto', "`{pattern}` timer is stopped with no members!\n" "Join {channel} to restart it." ) else: lazy_status = _p( 'cmd:timers|status:stopped_manual', "`{pattern}` timer is stopped with `{members}` members!\n" "Join {channel} and press `Start` to start it!" ) else: if stage.focused: lazy_status = _p( 'cmd:timers|status:running_focus', "`{pattern}` timer is running with `{members}` members!\n" "Currently **focusing**, with break starting {timestamp}" ) else: lazy_status = _p( 'cmd:timers|status:running_break', "`{pattern}` timer is running with `{members}` members!\n" "Currently **resting**, with focus starting {timestamp}" ) status = t(lazy_status).format( pattern=timer.pattern, channel=timer.channel.mention, members=len(timer.members), timestamp=f"" if stage else None ) embed.add_field(name=timer.channel.mention, value=status, inline=False) await ctx.reply(embed=embed, ephemeral=False) # -- Admin Commands -- @cmds.hybrid_group( name=_p('cmd:pomodoro', "pomodoro"), description=_p('cmd:pomodoro|desc', "Create and configure pomodoro timer rooms.") ) @cmds.guild_only() async def pomodoro_group(self, ctx: LionContext): ... @pomodoro_group.command( name=_p('cmd:pomodoro_create', "create"), description=_p( 'cmd:pomodoro_create|desc', "Create a new Pomodoro timer. Requires manage channel permissions." ) ) @appcmds.rename( channel=_p('cmd:pomodoro_create|param:channel', "timer_channel"), **{param: option._display_name for param, (option, _) in _param_options.items()} ) @appcmds.describe( channel=_p( 'cmd:pomodoro_create|param:channel|desc', "Voice channel to create the timer in. (Defaults to your current channel, or makes a new one.)" ), **{param: option._desc for param, (option, _) in _param_options.items()} ) async def cmd_pomodoro_create(self, ctx: LionContext, focus_length: appcmds.Range[int, 1, 24*60], break_length: appcmds.Range[int, 1, 24*60], channel: Optional[discord.VoiceChannel] = None, notification_channel: Optional[discord.TextChannel | discord.VoiceChannel] = None, inactivity_threshold: Optional[appcmds.Range[int, 0, 127]] = None, manager_role: Optional[discord.Role] = None, voice_alerts: Optional[bool] = None, name: Optional[appcmds.Range[str, 0, 100]] = None, channel_name: Optional[appcmds.Range[str, 0, 100]] = None, ): t = self.bot.translator.t # Type guards if not ctx.guild: return if not ctx.interaction: return # Get private room if applicable room_cog = self.bot.get_cog('RoomCog') if room_cog is None: logger.warning("Running pomodoro create without private room cog loaded!") private_room = None else: rooms = room_cog.get_rooms(ctx.guild.id, ctx.author.id) cid = next((cid for cid, room in rooms.items() if room.data.ownerid == ctx.author.id), None) private_room = ctx.guild.get_channel(cid) if cid is not None else None # If a voice channel was not given, attempt to resolve it or make one if channel is None: # Resolving order: command channel, author voice channel, new channel if ctx.channel.type is discord.ChannelType.voice: channel = ctx.channel elif ctx.author.voice and ctx.author.voice.channel: channel = ctx.author.voice.channel elif not ctx.author.guild_permissions.manage_channels: embed = discord.Embed( colour=discord.Colour.brand_red(), title=t(_p( 'cmd:pomodoro_create|new_channel|error:your_insufficient_perms|title', "Could not create pomodoro voice channel!" )), description=t(_p( 'cmd:pomodoro_create|new_channel|error:your_insufficient_perms', "No `timer_channel` was provided, and you lack the 'Manage Channels` permission " "required to create a new timer room!" )) ) await ctx.reply(embed=embed, ephemeral=True) elif not ctx.guild.me.guild_permissions.manage_channels: # Error embed = discord.Embed( colour=discord.Colour.brand_red(), title=t(_p( 'cmd:pomodoro_create|new_channel|error:my_insufficient_perms|title', "Could not create pomodoro voice channel!" )), description=t(_p( 'cmd:pomodoro_create|new_channel|error:my_insufficient_perms|desc', "No `timer_channel` was provided, and I lack the 'Manage Channels' permission " "required to create a new voice channel." )) ) await ctx.reply(embed=embed, ephemeral=True) else: # Attempt to create new channel in current category try: channel = await ctx.guild.create_voice_channel( name=name or t(_p( 'cmd:pomodoro_create|new_channel|default_name', "Timer" )), reason=t(_p( 'cmd:pomodoro_create|new_channel|audit_reason', "Creating Pomodoro Voice Channel" )), category=ctx.channel.category ) except discord.HTTPException: embed = discord.Embed( colour=discord.Colour.brand_red(), title=t(_p( 'cmd:pomodoro_create|new_channel|error:channel_create_failed|title', "Could not create pomodoro voice channel!" )), description=t(_p( 'cmd:pomodoro_create|new_channel|error:channel_create_failed|desc', "Failed to create a new pomodoro voice channel due to an unknown " "Discord communication error. " "Please try creating the channel manually and pass it to the " "`timer_channel` argument of this command." )) ) await ctx.reply(embed=embed, ephemeral=True) if not channel: # Already handled the creation error pass elif (self.get_channel_timer(channel.id)) is not None: # A timer already exists in the resolved channel embed = discord.Embed( colour=discord.Colour.brand_red(), description=t(_p( 'cmd:pomodoro_create|add_timer|error:timer_exists', "A timer already exists in {channel}! " "Reconfigure it with {edit_cmd}." )).format( channel=channel.mention, edit_cmd=self.bot.core.mention_cmd('pomodoro edit') ) ) await ctx.reply(embed=embed, ephemeral=True) elif not channel.permissions_for(ctx.author).manage_channels: # Note that this takes care of private room owners as well embed = discord.Embed( colour=discord.Colour.brand_red(), description=t(_p( 'cmd:pomodoro_create|add_timer|error:your_insufficient_perms', "You must have the 'Manage Channel' permission in {channel} " "in order to add a timer there!" )) ) await ctx.reply(embed=embed, ephemeral=True) else: # Finally, we are sure they can create a timer here # Build the creation arguments from the rest of the provided args provided = { 'focus_length': focus_length * 60, 'break_length': break_length * 60, 'inactivity_threshold': inactivity_threshold, 'voice_alerts': voice_alerts, 'name': name or channel.name, 'channel_name': channel_name or None, } create_args = {'channelid': channel.id, 'guildid': channel.guild.id} owned = (private_room and (channel == private_room)) if owned: provided['manager_role'] = manager_role or ctx.guild.default_role create_args['notification_channelid'] = channel.id create_args['ownerid'] = ctx.author.id else: provided['notification_channel'] = notification_channel provided['manager_role'] = manager_role for param, value in provided.items(): if value is not None: setting, _ = _param_options[param] create_args[setting._column] = setting._data_from_value(channel.id, value) # Permission checks and input checking done await ctx.interaction.response.defer(thinking=True) # Create timer timer = await self.create_timer(**create_args) # Start timer await timer.start() # Ack with a config UI ui = TimerOptionsUI( self.bot, timer, TimerRole.ADMIN if not owned else TimerRole.OWNER, callerid=ctx.author.id ) await ui.run( ctx.interaction, content=t(_p( 'cmd:pomodoro_create|response:success|content', "Timer created successfully! Use the panel below to reconfigure." )) ) await ui.wait() @pomodoro_group.command( name=_p('cmd:pomodoro_destroy', "destroy"), description=_p( 'cmd:pomodoro_destroy|desc', "Remove a pomodoro timer from a voice channel." ) ) @appcmds.rename( channel=_p('cmd:pomodoro_destroy|param:channel', "timer_channel"), ) @appcmds.describe( channel=_p('cmd:pomodoro_destroy|param:channel', "Select a timer voice channel to remove the timer from."), ) async def cmd_pomodoro_delete(self, ctx: LionContext, channel: discord.VoiceChannel): t = self.bot.translator.t # Type guards if not ctx.guild: return if not ctx.interaction: return # Check the timer actually exists timer = self.get_channel_timer(channel.id) if timer is None: embed = discord.Embed( colour=discord.Colour.brand_red(), description=t(_p( 'cmd:pomodoro_destroy|error:no_timer', "This channel doesn't have an attached pomodoro timer!" )) ) await ctx.interaction.response.send_message(embed=embed, ephemeral=True) return # Check the user has sufficient permissions to delete the timer timer_role = timer.get_member_role(ctx.author) if timer.owned and timer_role < TimerRole.OWNER: embed = discord.Embed( colour=discord.Colour.brand_red(), description=t(_p( 'cmd:pomodoro_destroy|error:insufficient_perms|owned', "You need to be an administrator or own this channel to remove this timer!" )) ) await ctx.interaction.response.send_message(embed=embed, ephemeral=True) elif timer_role is not TimerRole.ADMIN and not channel.permissions_for(ctx.author).manage_channels: embed = discord.Embed( colour=discord.Colour.brand_red(), description=t(_p( 'cmd:pomodoro_destroy|error:insufficient_perms|notowned', "You need to have the `Manage Channels` permission in {channel} to remove this timer!" )).format(channel=channel.mention) ) await ctx.interaction.response.send_message(embed=embed, ephemeral=True) else: await ctx.interaction.response.defer(thinking=True) await self.destroy_timer(timer, reason="Deleted by command") embed = discord.Embed( colour=discord.Colour.brand_green(), description=t(_p( 'cmd:pomdoro_destroy|response:success|description', "Timer successfully removed from {channel}." )).format(channel=channel.mention) ) await ctx.interaction.edit_original_response(embed=embed) @pomodoro_group.command( name=_p('cmd:pomodoro_edit', "edit"), description=_p( 'cmd:pomodoro_edit|desc', "Reconfigure a pomodoro timer." ) ) @appcmds.rename( channel=_p('cmd:pomodoro_edit|param:channel', "timer_channel"), **{param: option._display_name for param, (option, _) in _param_options.items()} ) @appcmds.describe( channel=_p( 'cmd:pomodoro_edit|param:channel|desc', "Select a timer voice channel to reconfigure." ), **{param: option._desc for param, (option, _) in _param_options.items()} ) async def cmd_pomodoro_edit(self, ctx: LionContext, channel: discord.VoiceChannel, focus_length: Optional[appcmds.Range[int, 1, 24*60]] = None, break_length: Optional[appcmds.Range[int, 1, 24*60]] = None, notification_channel: Optional[discord.TextChannel | discord.VoiceChannel] = None, inactivity_threshold: Optional[appcmds.Range[int, 0, 127]] = None, manager_role: Optional[discord.Role] = None, voice_alerts: Optional[bool] = None, name: Optional[appcmds.Range[str, 0, 100]] = None, channel_name: Optional[appcmds.Range[str, 0, 100]] = None, ): t = self.bot.translator.t provided = { 'focus_length': focus_length * 60 if focus_length else None, 'break_length': break_length * 60 if break_length else None, 'notification_channel': notification_channel, 'inactivity_threshold': inactivity_threshold, 'manager_role': manager_role, 'voice_alerts': voice_alerts, 'name': name or None, 'channel_name': channel_name or None, } modified = set(param for param, value in provided.items() if value is not None) # Type guards if not ctx.guild: return if not ctx.interaction: return # Check the timer actually exists timer = self.get_channel_timer(channel.id) if timer is None: embed = discord.Embed( colour=discord.Colour.brand_red(), description=t(_p( 'cmd:pomodoro_edit|error:no_timer', "This channel doesn't have an attached pomodoro timer to edit!" )) ) await ctx.interaction.response.send_message(embed=embed, ephemeral=True) return # Check that the author has sufficient permissions to update the timer at all timer_role = timer.get_member_role(ctx.author) if timer_role is TimerRole.OTHER: embed = discord.Embed( colour=discord.Colour.brand_red(), description=t(_p( 'cmd:pomodoro_edit|error:insufficient_perms|role:other', "Insufficient permissions to modifiy this timer!\n" "You need to be a server administrator, own this channel, or have the timer manager role." )) ) await ctx.reply(embed=embed, ephemeral=True) return # Check that the author has sufficient permissions to modify the requested items # And build the list of arguments to write update_args = {} for param in modified: setting, required = _param_options[param] if timer_role < required: if required is TimerRole.OWNER and not timer.owned: required = TimerRole.ADMIN elif required is TimerRole.MANAGER and timer.data.manager_roleid is None: required = TimerRole.ADMIN if required is TimerRole.ADMIN: error = t(_p( 'cmd:pomodoro_edit|error:insufficient_permissions|role_needed:admin', "You need to be a guild admin to modify this option!" )) elif required is TimerRole.OWNER: error = t(_p( 'cmd:pomodoro_edit|error:insufficient_permissions|role_needed:owner', "You need to be a channel owner or guild admin to modify this option!" )) elif required is TimerRole.MANAGER: error = t(_p( 'cmd:pomodoro_edit|error:insufficient_permissions|role_needed:manager', "You need to be a guild admin or have the manager role to modify this option!" )) embed = discord.Embed( colour=discord.Colour.brand_red(), description=error ) await ctx.reply(embed=embed, ephemeral=True) return update_args[setting._column] = setting._data_from_value(channel.id, provided[param]) await ctx.interaction.response.defer(thinking=True) if update_args: # Update the timer data await timer.data.update(**update_args) # Regenerate or refresh the timer if ('focus_length' in modified) or ('break_length' in modified): await timer.start() elif ('notification_channel' in modified): await timer.send_status() else: await timer.update_status_card() # Show the config UI ui = TimerOptionsUI(self.bot, timer, timer_role, callerid=ctx.author.id) await ui.run(ctx.interaction) await ui.wait() # ----- Guild Config Commands ----- @LionCog.placeholder_group @cmds.hybrid_group('configure', with_app_command=False) async def configure_group(self, ctx: LionContext): ... @configure_group.command( name=_p('cmd:configure_pomodoro', "pomodoro"), description=_p('cmd:configure_pomodoro|desc', "Configure Pomodoro Timer System") ) @appcmds.rename( pomodoro_channel=TimerSettings.PomodoroChannel._display_name ) @appcmds.describe( pomodoro_channel=TimerSettings.PomodoroChannel._desc ) @low_management_ward async def configure_pomodoro_command(self, ctx: LionContext, pomodoro_channel: Optional[discord.VoiceChannel | discord.TextChannel] = None): # Type checking guards if not ctx.guild: return if not ctx.interaction: return await ctx.interaction.response.defer(thinking=True) pomodoro_channel_setting = await self.settings.PomodoroChannel.get(ctx.guild.id) if pomodoro_channel is not None: # VALIDATE PERMISSIONS! pomodoro_channel_setting.value = pomodoro_channel await pomodoro_channel_setting.write() modified = True else: modified = False if modified: line = pomodoro_channel_setting.update_message embed = discord.Embed( colour=discord.Colour.brand_green(), description=f"{self.bot.config.emojis.tick} {line}" ) await ctx.reply(embed=embed) if ctx.channel.id not in TimerConfigUI._listening or not modified: ui = TimerConfigUI(self.bot, ctx.guild.id, ctx.channel.id) await ui.run(ctx.interaction) await ui.wait() # ----- Hacky Stream commands ----- @cmds.hybrid_group('streamtimer', with_app_command=True) async def streamtimer_group(self, ctx: LionContext): ... @streamtimer_group.command( name="update" ) @low_management_ward async def streamtimer_update_cmd(self, ctx: LionContext, new_start: Optional[str] = None, new_goal: Optional[int] = None, new_channel: Optional[discord.VoiceChannel] = None, ): if new_channel is not None: channelid = self.channel.channelid = new_channel.id else: channelid = self.channel.channelid if new_goal is not None: self.channel.goal = new_goal timer = self.get_channel_timer(channelid) if timer is None: return if new_start: timezone = ctx.lmember.timezone start_at = await self.bot.get_cog('Reminders').parse_time_static(new_start, timezone) await timer.data.update(last_started=start_at) await self.channel.send_updates() await ctx.reply("Stream Timer Updated")