Files
croccybot/src/modules/pomodoro/cog.py
Interitio 9c9107bf9d fix(timers): Remove user from last_seen on leave.
Fixes an issue where user inactivity was inaccurately tracked on rejoin.
2024-09-26 01:46:39 +10:00

1098 lines
44 KiB
Python

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 = (
"<TimerState"
" loaded={loaded}"
" guilds={guilds}"
" members={members}"
" running={running}"
" launched={launched}"
" looping={looping}"
" locked={locked}"
" voice_locked={voice_locked}"
">"
)
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 <gid: {guild.id}>."
)
@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"<t:{int(stage.end.timestamp())}:R>" 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")