Merge branch 'rewrite' into pillow
This commit is contained in:
@@ -9,10 +9,10 @@ active = [
|
||||
'.ranks',
|
||||
'.reminders',
|
||||
'.shop',
|
||||
'.tasklist',
|
||||
'.statistics',
|
||||
'.pomodoro',
|
||||
'.rooms',
|
||||
'.tasklist',
|
||||
'.rolemenus',
|
||||
'.member_admin',
|
||||
'.moderation',
|
||||
|
||||
@@ -190,7 +190,7 @@ class Economy(LionCog):
|
||||
# First fetch the members which currently exist
|
||||
query = self.bot.core.data.Member.table.select_where(guildid=ctx.guild.id)
|
||||
query.select('userid').with_no_adapter()
|
||||
if 2 * len(targets) < len(ctx.guild.members):
|
||||
if 2 * len(targets) < ctx.guild.member_count:
|
||||
# More efficient to fetch the targets explicitly
|
||||
query.where(userid=list(targetids))
|
||||
existent_rows = await query
|
||||
@@ -381,13 +381,28 @@ class Economy(LionCog):
|
||||
if role:
|
||||
query = MemModel.table.select_where(
|
||||
(MemModel.guildid == role.guild.id) & (MemModel.coins != 0)
|
||||
)
|
||||
query.order_by('coins', ORDER.DESC)
|
||||
).with_no_adapter()
|
||||
if not role.is_default():
|
||||
# Everyone role is handled differently for data efficiency
|
||||
ids = [target.id for target in targets]
|
||||
query = query.where(userid=ids)
|
||||
rows = await query
|
||||
|
||||
# First get a summary
|
||||
summary = await query.select(
|
||||
_count='COUNT(*)',
|
||||
_coin_total='SUM(coins)',
|
||||
)
|
||||
record = summary[0]
|
||||
count = record['_count']
|
||||
total = record['_coin_total']
|
||||
if count > 0:
|
||||
# Then get the top 1000 members
|
||||
query._columns = ()
|
||||
query.order_by('coins', ORDER.DESC)
|
||||
query.limit(1000)
|
||||
rows = await query.select('userid', 'coins')
|
||||
else:
|
||||
rows = []
|
||||
|
||||
name = t(_p(
|
||||
'cmd:economy_balance|embed:role_lb|author',
|
||||
@@ -400,7 +415,7 @@ class Economy(LionCog):
|
||||
"This server has a total balance of {coin_emoji}**{total}**."
|
||||
)).format(
|
||||
coin_emoji=cemoji,
|
||||
total=sum(row['coins'] for row in rows)
|
||||
total=total
|
||||
)
|
||||
else:
|
||||
header = t(_p(
|
||||
@@ -408,9 +423,9 @@ class Economy(LionCog):
|
||||
"{role_mention} has `{count}` members with non-zero balance, "
|
||||
"with a total balance of {coin_emoji}**{total}**."
|
||||
)).format(
|
||||
count=len(targets),
|
||||
count=count,
|
||||
role_mention=role.mention,
|
||||
total=sum(row['coins'] for row in rows),
|
||||
total=total,
|
||||
coin_emoji=cemoji
|
||||
)
|
||||
|
||||
@@ -476,7 +491,7 @@ class Economy(LionCog):
|
||||
else:
|
||||
# If we have a single target, show their current balance, with a short transaction history.
|
||||
user = targets[0]
|
||||
row = await self.bot.core.data.Member.fetch(ctx.guild.id, user.id)
|
||||
row = await self.bot.core.data.Member.fetch(ctx.guild.id, user.id, cached=False)
|
||||
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
@@ -675,7 +690,7 @@ class Economy(LionCog):
|
||||
)
|
||||
@appcmds.guild_only()
|
||||
async def send_cmd(self, ctx: LionContext,
|
||||
target: discord.User | discord.Member,
|
||||
target: discord.Member,
|
||||
amount: appcmds.Range[int, 1, MAX_COINS],
|
||||
note: Optional[str] = None):
|
||||
"""
|
||||
@@ -690,17 +705,49 @@ class Economy(LionCog):
|
||||
|
||||
t = self.bot.translator.t
|
||||
|
||||
error = None
|
||||
if not ctx.lguild.config.get('allow_transfers').value:
|
||||
await ctx.interaction.response.send_message(
|
||||
embed=error_embed(
|
||||
t(_p(
|
||||
'cmd:send|error:not_allowed',
|
||||
"Sorry, this server has disabled LionCoin transfers!"
|
||||
))
|
||||
)
|
||||
error = error_embed(
|
||||
t(_p(
|
||||
'cmd:send|error:not_allowed',
|
||||
"Sorry, this server has disabled LionCoin transfers!"
|
||||
))
|
||||
)
|
||||
elif target == ctx.author:
|
||||
# Funny response
|
||||
error = discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
description=t(_p( # TRANSLATOR NOTE: Easter egg/Funny error, translate as you wish.
|
||||
'cmd:send|error:sending-to-self',
|
||||
"What is this, tax evasion?\n"
|
||||
"(You can not send coins to yourself.)"
|
||||
))
|
||||
)
|
||||
elif target == ctx.guild.me:
|
||||
# Funny response
|
||||
error = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
description=t(_p( # TRANSLATOR NOTE: Easter egg/Funny error, translate as you wish.
|
||||
'cmd:send|error:sending-to-leo',
|
||||
"I appreciate it, but you need it more than I do!\n"
|
||||
"(You cannot send coins to bots.)"
|
||||
))
|
||||
)
|
||||
elif target.bot:
|
||||
# Funny response
|
||||
error = discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
description=t(_p( # TRANSLATOR NOTE: Easter egg/Funny error, translate as you wish.
|
||||
'cmd:send|error:sending-to-bot',
|
||||
"{target} appreciates the gesture, but said they don't have any use for {coin}.\n"
|
||||
"(You cannot send coins to bots.)"
|
||||
)).format(target=target.mention, coin=self.bot.config.emojis.coin)
|
||||
)
|
||||
if error is not None:
|
||||
await ctx.interaction.response.send_message(embed=error, ephemeral=True)
|
||||
return
|
||||
|
||||
# Ensure the target member exists
|
||||
Member = self.bot.core.data.Member
|
||||
target_lion = await self.bot.core.lions.fetch_member(ctx.guild.id, target.id)
|
||||
|
||||
@@ -778,7 +825,7 @@ class Economy(LionCog):
|
||||
)
|
||||
)
|
||||
if failed:
|
||||
embed.description = t(_p(
|
||||
embed.description += '\n' + t(_p(
|
||||
'cmd:send|embed:ack|desc|error:unreachable',
|
||||
"Unfortunately, I was not able to message the recipient. Perhaps they have me blocked?"
|
||||
))
|
||||
|
||||
@@ -8,6 +8,7 @@ from core.data import CoreData
|
||||
from utils.data import TemporaryTable, SAFECOINS
|
||||
|
||||
|
||||
# TODO: Add Rank transaction type and tables.
|
||||
class TransactionType(Enum):
|
||||
"""
|
||||
Schema
|
||||
|
||||
@@ -181,15 +181,17 @@ class MemberAdminCog(LionCog):
|
||||
finally:
|
||||
self._adding_roles.discard((member.guild.id, member.id))
|
||||
|
||||
@LionCog.listener('on_member_remove')
|
||||
@LionCog.listener('on_raw_member_remove')
|
||||
@log_wrap(action="Farewell")
|
||||
async def admin_member_farewell(self, member: discord.Member):
|
||||
async def admin_member_farewell(self, payload: discord.RawMemberRemoveEvent):
|
||||
# Ignore members that just joined
|
||||
if (member.guild.id, member.id) in self._adding_roles:
|
||||
guildid = payload.guild_id
|
||||
userid = payload.user.id
|
||||
if (guildid, userid) in self._adding_roles:
|
||||
return
|
||||
|
||||
# Set lion last_left, creating the lion_member if needed
|
||||
lion = await self.bot.core.lions.fetch_member(member.guild.id, member.id)
|
||||
lion = await self.bot.core.lions.fetch_member(guildid, userid)
|
||||
await lion.data.update(last_left=utc_now())
|
||||
|
||||
# Save member roles
|
||||
@@ -197,18 +199,21 @@ class MemberAdminCog(LionCog):
|
||||
self.bot.db.conn = conn
|
||||
async with conn.transaction():
|
||||
await self.data.past_roles.delete_where(
|
||||
guildid=member.guild.id,
|
||||
userid=member.id
|
||||
guildid=guildid,
|
||||
userid=userid
|
||||
)
|
||||
# Insert current member roles
|
||||
if member.roles:
|
||||
print(type(payload.user))
|
||||
if isinstance(payload.user, discord.Member) and payload.user.roles:
|
||||
member = payload.user
|
||||
await self.data.past_roles.insert_many(
|
||||
('guildid', 'userid', 'roleid'),
|
||||
*((member.guild.id, member.id, role.id) for role in member.roles)
|
||||
*((guildid, userid, role.id) for role in member.roles)
|
||||
)
|
||||
logger.debug(
|
||||
f"Stored persisting roles for member <uid:{member.id}> in <gid:{member.guild.id}>."
|
||||
f"Stored persisting roles for member <uid:{userid}> in <gid:{guildid}>."
|
||||
)
|
||||
# TODO: Event log, and include info about unchunked members
|
||||
|
||||
@LionCog.listener('on_guild_join')
|
||||
async def admin_init_guild(self, guild: discord.Guild):
|
||||
|
||||
@@ -173,7 +173,7 @@ class MemberAdminSettings(SettingGroup):
|
||||
'{guild_name}': guild.name,
|
||||
'{guild_icon}': guild.icon.url if guild.icon else member.default_avatar.url,
|
||||
'{studying_count}': str(active),
|
||||
'{member_count}': len(guild.members),
|
||||
'{member_count}': guild.member_count,
|
||||
}
|
||||
|
||||
recurse_map(
|
||||
@@ -297,7 +297,7 @@ class MemberAdminSettings(SettingGroup):
|
||||
'{guild_name}': guild.name,
|
||||
'{guild_icon}': guild.icon.url if guild.icon else member.default_avatar.url,
|
||||
'{studying_count}': str(active),
|
||||
'{member_count}': str(len(guild.members)),
|
||||
'{member_count}': str(guild.member_count),
|
||||
'{last_time}': str(last_seen or member.joined_at.timestamp()),
|
||||
}
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ class MemberAdminUI(ConfigUI):
|
||||
t = self.bot.translator.t
|
||||
title = t(_p(
|
||||
'ui:memberadmin|embed|title',
|
||||
"Member Admin Configuration Panel"
|
||||
"Greetings and Initial Roles Panel"
|
||||
))
|
||||
embed = discord.Embed(
|
||||
title=title,
|
||||
|
||||
@@ -32,6 +32,6 @@ class MetaCog(LionCog):
|
||||
ctx.bot,
|
||||
ctx.author,
|
||||
ctx.guild,
|
||||
show_admin=await low_management(ctx.bot, ctx.author),
|
||||
show_admin=await low_management(ctx.bot, ctx.author, ctx.guild),
|
||||
)
|
||||
await ui.run(ctx.interaction)
|
||||
|
||||
@@ -20,9 +20,9 @@ cmd_map = {
|
||||
"cmd_send": "send",
|
||||
"cmd_shop": "shop open",
|
||||
"cmd_room": "room rent",
|
||||
"cmd_reminders": "remindme in",
|
||||
"cmd_reminders": "reminders",
|
||||
"cmd_tasklist": "tasklist",
|
||||
"cmd_timers": "timers list",
|
||||
"cmd_timers": "timers",
|
||||
"cmd_schedule": "schedule",
|
||||
"cmd_dashboard": "dashboard"
|
||||
}
|
||||
@@ -79,8 +79,8 @@ admin_extra = _p(
|
||||
|
||||
Other relevant commands for guild configuration below:
|
||||
`/editshop`: Add/Edit/Remove colour roles from the {coin} shop.
|
||||
`/ranks`: Add/Edit/Remove activity ranks.
|
||||
`/timer admin`: Add/Edit/Remove Pomodoro timers in voice channels.
|
||||
`/ranks`: Add/Edit/Refresh/Remove activity ranks.
|
||||
`/pomodoro`: Add/Edit/Remove Pomodoro timers in voice channels.
|
||||
`/rolemenus`: Allow members to equip roles from customisable messages.
|
||||
`/economy balance`: Display and modify LionCoin balance for members and roles.
|
||||
"""
|
||||
|
||||
@@ -128,7 +128,7 @@ class ModerationSettings(SettingGroup):
|
||||
_long_desc = _p(
|
||||
'guildset:mod_role|long_desc',
|
||||
"Members with the set role will be able to access my configuration panels, "
|
||||
"and perform some moderation tasks, such us setting up pomodoro timers. "
|
||||
"and perform some moderation tasks, such as setting up pomodoro timers. "
|
||||
"Moderators cannot reconfigure most bot configuration, "
|
||||
"or perform operations they do not already have permission for in Discord."
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 wards import low_management_ward
|
||||
@@ -21,7 +22,6 @@ from .settings import TimerSettings
|
||||
from .settingui import TimerConfigUI
|
||||
from .timer import Timer
|
||||
from .options import TimerOptions
|
||||
from .ui import TimerStatusUI
|
||||
from .ui.config import TimerOptionsUI
|
||||
|
||||
_p = babel._p
|
||||
@@ -43,12 +43,25 @@ class TimerCog(LionCog):
|
||||
self.bot = bot
|
||||
self.data = bot.db.load_registry(TimerData())
|
||||
self.settings = TimerSettings()
|
||||
self.monitor = ComponentMonitor('TimerCog', self._monitor)
|
||||
|
||||
self.timer_options = TimerOptions()
|
||||
|
||||
self.ready = False
|
||||
self.timers = defaultdict(dict)
|
||||
|
||||
async def _monitor(self):
|
||||
if not self.ready:
|
||||
level = StatusLevel.STARTING
|
||||
info = "(STARTING) Not ready. {timers} timers loaded."
|
||||
else:
|
||||
level = StatusLevel.OKAY
|
||||
info = "(OK) {timers} timers loaded."
|
||||
data = dict(timers=len(self.timers))
|
||||
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)
|
||||
@@ -319,29 +332,24 @@ class TimerCog(LionCog):
|
||||
await timer.destroy(**kwargs)
|
||||
|
||||
# ----- Timer Commands -----
|
||||
@cmds.hybrid_group(
|
||||
name=_p('cmd:pomodoro', "timers"),
|
||||
description=_p('cmd:pomodoro|desc', "Base group for all pomodoro timer commands.")
|
||||
)
|
||||
@cmds.guild_only()
|
||||
async def pomodoro_group(self, ctx: LionContext):
|
||||
...
|
||||
|
||||
# -- User Display Commands --
|
||||
@pomodoro_group.command(
|
||||
name=_p('cmd:pomodoro_status', "show"),
|
||||
description=_p('cmd:pomodoro_status|desc', "Display the status of a single pomodoro timer.")
|
||||
@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:pomodoro_status|param:channel', "timer_channel")
|
||||
channel=_p('cmd:timer|param:channel', "timer_channel")
|
||||
)
|
||||
@appcmds.describe(
|
||||
channel=_p(
|
||||
'cmd:pomodoro_status|param:channel|desc',
|
||||
"The channel for which you want to view the timer."
|
||||
'cmd:timer|param:channel|desc',
|
||||
"Select a timer to display (by selecting the timer voice channel)"
|
||||
)
|
||||
)
|
||||
async def cmd_pomodoro_status(self, ctx: LionContext, channel: discord.VoiceChannel):
|
||||
@cmds.guild_only()
|
||||
async def cmd_timer(self, ctx: LionContext,
|
||||
channel: Optional[discord.VoiceChannel] = None):
|
||||
t = self.bot.translator.t
|
||||
|
||||
if not ctx.guild:
|
||||
@@ -349,27 +357,64 @@ class TimerCog(LionCog):
|
||||
if not ctx.interaction:
|
||||
return
|
||||
|
||||
# Check if a timer exists in the given channel
|
||||
timer = self.get_channel_timer(channel.id)
|
||||
if timer is None:
|
||||
embed = discord.Embed(
|
||||
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:pomodoro_status|error:no_timer',
|
||||
"The channel {channel} does not have a timer set up!"
|
||||
)).format(channel=channel.mention)
|
||||
'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)
|
||||
else:
|
||||
# Display the timer status ephemerally
|
||||
status = await timer.current_status(with_notify=False, with_warnings=False)
|
||||
await ctx.reply(**status.send_args, ephemeral=True)
|
||||
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'))
|
||||
)
|
||||
|
||||
@pomodoro_group.command(
|
||||
name=_p('cmd:pomodoro_list', "list"),
|
||||
description=_p('cmd:pomodoro_list|desc', "List the available pomodoro 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.")
|
||||
)
|
||||
async def cmd_pomodoro_list(self, ctx: LionContext):
|
||||
@cmds.guild_only()
|
||||
async def cmd_timers(self, ctx: LionContext):
|
||||
t = self.bot.translator.t
|
||||
|
||||
if not ctx.guild:
|
||||
@@ -378,6 +423,8 @@ class TimerCog(LionCog):
|
||||
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
|
||||
@@ -385,26 +432,29 @@ class TimerCog(LionCog):
|
||||
]
|
||||
|
||||
if not timers:
|
||||
# No timers in this guild!
|
||||
# No timers in the guild
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
description=t(_p(
|
||||
'cmd:pomodoro_list|error:no_timers',
|
||||
"No timers have been setup in this server!\n"
|
||||
"You can ask an admin to create one with {command}, "
|
||||
"or rent a private room and create one yourself!"
|
||||
)).format(command='`/pomodoro admin create`')
|
||||
'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'))
|
||||
)
|
||||
# TODO: Update command mention when we have command mentions
|
||||
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:pomodoro_list|error:no_visible_timers',
|
||||
"There are no timers you can join in this server!"
|
||||
))
|
||||
'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:
|
||||
@@ -412,8 +462,8 @@ class TimerCog(LionCog):
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title=t(_p(
|
||||
'cmd:pomodoro_list|embed:timer_list|title',
|
||||
"Pomodoro Timers in **{guild}**"
|
||||
'cmd:timers|embed:timer_list|title',
|
||||
"Pomodoro Timer Rooms in **{guild}**"
|
||||
)).format(guild=ctx.guild.name),
|
||||
)
|
||||
for timer in visible_timers:
|
||||
@@ -421,25 +471,26 @@ class TimerCog(LionCog):
|
||||
if stage is None:
|
||||
if timer.auto_restart:
|
||||
lazy_status = _p(
|
||||
'cmd:pomodoro_list|status:stopped_auto',
|
||||
"`{pattern}` timer is stopped with no members!\nJoin {channel} to restart it."
|
||||
'cmd:timers|status:stopped_auto',
|
||||
"`{pattern}` timer is stopped with no members!\n"
|
||||
"Join {channel} to restart it."
|
||||
)
|
||||
else:
|
||||
lazy_status = _p(
|
||||
'cmd:pomodoro_list|status:stopped_manual',
|
||||
'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:pomodoro_list|status:running_focus',
|
||||
'cmd:timers|status:running_focus',
|
||||
"`{pattern}` timer is running with `{members}` members!\n"
|
||||
"Currently **focusing**, with break starting {timestamp}"
|
||||
)
|
||||
else:
|
||||
lazy_status = _p(
|
||||
'cmd:pomodoro_list|status:running_break',
|
||||
'cmd:timers|status:running_break',
|
||||
"`{pattern}` timer is running with `{members}` members!\n"
|
||||
"Currently **resting**, with focus starting {timestamp}"
|
||||
)
|
||||
@@ -453,18 +504,19 @@ class TimerCog(LionCog):
|
||||
await ctx.reply(embed=embed, ephemeral=False)
|
||||
|
||||
# -- Admin Commands --
|
||||
@pomodoro_group.group(
|
||||
name=_p('cmd:pomodoro_admin', "admin"),
|
||||
desc=_p('cmd:pomodoro_admin|desc', "Command group for pomodoro admin controls.")
|
||||
@cmds.hybrid_group(
|
||||
name=_p('cmd:pomodoro', "pomodoro"),
|
||||
description=_p('cmd:pomodoro|desc', "Create and configure pomodoro timer rooms.")
|
||||
)
|
||||
async def pomodoro_admin_group(self, ctx: LionContext):
|
||||
@cmds.guild_only()
|
||||
async def pomodoro_group(self, ctx: LionContext):
|
||||
...
|
||||
|
||||
@pomodoro_admin_group.command(
|
||||
@pomodoro_group.command(
|
||||
name=_p('cmd:pomodoro_create', "create"),
|
||||
description=_p(
|
||||
'cmd:pomodoro_create|desc',
|
||||
"Create a new Pomodoro timer. Requires admin permissions."
|
||||
"Create a new Pomodoro timer. Requires manage channel permissions."
|
||||
)
|
||||
)
|
||||
@appcmds.rename(
|
||||
@@ -497,17 +549,15 @@ class TimerCog(LionCog):
|
||||
if not ctx.interaction:
|
||||
return
|
||||
|
||||
# Check permissions
|
||||
if not ctx.author.guild_permissions.administrator:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
description=t(_p(
|
||||
'cmd:pomodoro_create|error:insufficient_perms',
|
||||
"Only server administrators can create timers!"
|
||||
))
|
||||
)
|
||||
await ctx.reply(embed=embed, ephemeral=True)
|
||||
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:
|
||||
@@ -516,112 +566,155 @@ class TimerCog(LionCog):
|
||||
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
|
||||
if ctx.guild.me.guild_permissions.manage_channels:
|
||||
try:
|
||||
channel = await ctx.guild.create_voice_channel(
|
||||
name=name or "Timer",
|
||||
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|error:channel_create_failed|title',
|
||||
"Could not create pomodoro voice channel!"
|
||||
)),
|
||||
description=t(_p(
|
||||
'cmd:pomodoro_create|error:channel_create|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)
|
||||
return
|
||||
else:
|
||||
# Error
|
||||
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|error:channel_create_permissions|title',
|
||||
'cmd:pomodoro_create|new_channel|error:channel_create_failed|title',
|
||||
"Could not create pomodoro voice channel!"
|
||||
)),
|
||||
description=t(_p(
|
||||
'cmd:pomodoro_create|error:channel_create_permissions|desc',
|
||||
"No `timer_channel` was provided, and I lack the `MANAGE_CHANNELS` permission "
|
||||
"needed to create a new voice channel."
|
||||
'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)
|
||||
return
|
||||
|
||||
# At this point, we have a voice channel
|
||||
# Make sure a timer does not already exist in the channel
|
||||
if (self.get_channel_timer(channel.id)) is not None:
|
||||
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|error:timer_exists',
|
||||
"A timer already exists in {channel}! Use `/pomodoro admin edit` to modify it."
|
||||
)).format(channel=channel.mention)
|
||||
'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)
|
||||
return
|
||||
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}
|
||||
|
||||
# Build the creation arguments from the rest of the provided args
|
||||
provided = {
|
||||
'focus_length': focus_length * 60,
|
||||
'break_length': break_length * 60,
|
||||
'notification_channel': notification_channel,
|
||||
'inactivity_threshold': inactivity_threshold,
|
||||
'manager_role': manager_role,
|
||||
'voice_alerts': voice_alerts,
|
||||
'name': name or channel.name,
|
||||
'channel_name': channel_name or None,
|
||||
}
|
||||
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
|
||||
|
||||
create_args = {'channelid': channel.id, 'guildid': channel.guild.id}
|
||||
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)
|
||||
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)
|
||||
# Permission checks and input checking done
|
||||
await ctx.interaction.response.defer(thinking=True)
|
||||
|
||||
# Create timer
|
||||
timer = await self.create_timer(**create_args)
|
||||
# Create timer
|
||||
timer = await self.create_timer(**create_args)
|
||||
|
||||
# Start timer
|
||||
await timer.start()
|
||||
# Start timer
|
||||
await timer.start()
|
||||
|
||||
# Ack with a config UI
|
||||
ui = TimerOptionsUI(self.bot, timer, TimerRole.ADMIN, 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()
|
||||
# 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_admin_group.command(
|
||||
@pomodoro_group.command(
|
||||
name=_p('cmd:pomodoro_destroy', "destroy"),
|
||||
description=_p(
|
||||
'cmd:pomodoro_destroy|desc',
|
||||
"Delete a pomodoro timer from a voice channel. Requires admin permissions."
|
||||
"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', "Channel with the timer to delete."),
|
||||
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
|
||||
@@ -646,46 +739,42 @@ class TimerCog(LionCog):
|
||||
return
|
||||
|
||||
# Check the user has sufficient permissions to delete the timer
|
||||
# TODO: Should we drop the admin requirement down to manage channel?
|
||||
timer_role = timer.get_member_role(ctx.author)
|
||||
if timer.owned:
|
||||
if 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)
|
||||
return
|
||||
elif timer_role is not TimerRole.ADMIN:
|
||||
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 be a server administrator to remove this timer!"
|
||||
))
|
||||
"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)
|
||||
return
|
||||
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)
|
||||
|
||||
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_admin_group.command(
|
||||
@pomodoro_group.command(
|
||||
name=_p('cmd:pomodoro_edit', "edit"),
|
||||
description=_p(
|
||||
'cmd:pomodoro_edit|desc',
|
||||
"Edit a Timer"
|
||||
"Reconfigure a pomodoro timer."
|
||||
)
|
||||
)
|
||||
@appcmds.rename(
|
||||
@@ -695,7 +784,7 @@ class TimerCog(LionCog):
|
||||
@appcmds.describe(
|
||||
channel=_p(
|
||||
'cmd:pomodoro_edit|param:channel|desc',
|
||||
"Channel holding the timer to edit."
|
||||
"Select a timer voice channel to reconfigure."
|
||||
),
|
||||
**{param: option._desc for param, (option, _) in _param_options.items()}
|
||||
)
|
||||
@@ -829,8 +918,6 @@ class TimerCog(LionCog):
|
||||
@low_management_ward
|
||||
async def configure_pomodoro_command(self, ctx: LionContext,
|
||||
pomodoro_channel: Optional[discord.VoiceChannel | discord.TextChannel] = None):
|
||||
t = self.bot.translator.t
|
||||
|
||||
# Type checking guards
|
||||
if not ctx.guild:
|
||||
return
|
||||
|
||||
@@ -4,6 +4,7 @@ import discord
|
||||
|
||||
from meta import LionBot
|
||||
from meta.errors import UserInputError
|
||||
from utils.lib import replace_multiple
|
||||
from babel.translator import ctx_translator
|
||||
from settings import ModelData
|
||||
from settings.groups import SettingGroup, ModelConfig, SettingDotDict
|
||||
@@ -37,6 +38,8 @@ class TimerOptions(SettingGroup):
|
||||
|
||||
_model = TimerData.Timer
|
||||
_column = TimerData.Timer.channelid.name
|
||||
_create_row = False
|
||||
_allow_object = False
|
||||
|
||||
@TimerConfig.register_model_setting
|
||||
class NotificationChannel(ModelData, ChannelSetting):
|
||||
@@ -50,6 +53,8 @@ class TimerOptions(SettingGroup):
|
||||
|
||||
_model = TimerData.Timer
|
||||
_column = TimerData.Timer.notification_channelid.name
|
||||
_create_row = False
|
||||
_allow_object = False
|
||||
|
||||
@classmethod
|
||||
async def _check_value(cls, parent_id: int, value: Optional[discord.abc.GuildChannel], **kwargs):
|
||||
@@ -86,6 +91,7 @@ class TimerOptions(SettingGroup):
|
||||
)
|
||||
_model = TimerData.Timer
|
||||
_column = TimerData.Timer.inactivity_threshold.name
|
||||
_create_row = False
|
||||
|
||||
_min = 0
|
||||
_max = 64
|
||||
@@ -94,6 +100,19 @@ class TimerOptions(SettingGroup):
|
||||
def input_formatted(self):
|
||||
return str(self._data) if self._data is not None else ''
|
||||
|
||||
@classmethod
|
||||
async def _parse_string(cls, parent_id, string, **kwargs):
|
||||
try:
|
||||
return await super()._parse_string(parent_id, string, **kwargs)
|
||||
except UserInputError:
|
||||
t = ctx_translator.get().t
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'timerset:inactivity_length|desc',
|
||||
"The inactivity threshold must be a positive whole number!"
|
||||
))
|
||||
)
|
||||
|
||||
@TimerConfig.register_model_setting
|
||||
class ManagerRole(ModelData, RoleSetting):
|
||||
setting_id = 'manager_role'
|
||||
@@ -106,6 +125,8 @@ class TimerOptions(SettingGroup):
|
||||
|
||||
_model = TimerData.Timer
|
||||
_column = TimerData.Timer.manager_roleid.name
|
||||
_create_row = False
|
||||
_allow_object = False
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, parent_id, data, timer=None, **kwargs):
|
||||
@@ -132,6 +153,7 @@ class TimerOptions(SettingGroup):
|
||||
|
||||
_model = TimerData.Timer
|
||||
_column = TimerData.Timer.voice_alerts.name
|
||||
_create_row = False
|
||||
|
||||
@TimerConfig.register_model_setting
|
||||
class BaseName(ModelData, StringSetting):
|
||||
@@ -153,6 +175,7 @@ class TimerOptions(SettingGroup):
|
||||
|
||||
_model = TimerData.Timer
|
||||
_column = TimerData.Timer.pretty_name.name
|
||||
_create_row = False
|
||||
|
||||
@TimerConfig.register_model_setting
|
||||
class ChannelFormat(ModelData, StringSetting):
|
||||
@@ -172,6 +195,43 @@ class TimerOptions(SettingGroup):
|
||||
|
||||
_model = TimerData.Timer
|
||||
_column = TimerData.Timer.channel_name.name
|
||||
_create_row = False
|
||||
|
||||
@classmethod
|
||||
async def _parse_string(cls, parent_id, string, **kwargs):
|
||||
# Enforce a length limit on a test-rendered string.
|
||||
# TODO: Localised formatkey transformation
|
||||
if string.lower() in ('', 'none', 'default'):
|
||||
# Special cases for unsetting
|
||||
return None
|
||||
|
||||
testmap = {
|
||||
'{remaining}': "10m",
|
||||
'{name}': "Longish name",
|
||||
'{stage}': "FOCUS",
|
||||
'{members}': "25",
|
||||
'{pattern}': "50/10",
|
||||
}
|
||||
testmapped = replace_multiple(string, testmap)
|
||||
if len(testmapped) > 100:
|
||||
t = ctx_translator.get().t
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'timerset:channel_name_format|error:too_long',
|
||||
"The provided name is too long! Channel names can be at most `100` characters."
|
||||
))
|
||||
)
|
||||
else:
|
||||
return string
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, parent_id, data, **kwargs):
|
||||
"""
|
||||
Overriding format to truncate displayed string.
|
||||
"""
|
||||
if data is not None and len(data) > 100:
|
||||
data = data[:97] + '...'
|
||||
return super()._format_data(parent_id, data, **kwargs)
|
||||
|
||||
@TimerConfig.register_model_setting
|
||||
class FocusLength(ModelData, DurationSetting):
|
||||
@@ -191,6 +251,7 @@ class TimerOptions(SettingGroup):
|
||||
|
||||
_model = TimerData.Timer
|
||||
_column = TimerData.Timer.focus_length.name
|
||||
_create_row = False
|
||||
|
||||
_default_multiplier = 60
|
||||
allow_zero = False
|
||||
@@ -231,6 +292,7 @@ class TimerOptions(SettingGroup):
|
||||
|
||||
_model = TimerData.Timer
|
||||
_column = TimerData.Timer.break_length.name
|
||||
_create_row = False
|
||||
|
||||
_default_multiplier = 60
|
||||
allow_zero = False
|
||||
|
||||
@@ -12,6 +12,7 @@ from utils.lib import MessageArgs, utc_now, replace_multiple
|
||||
from core.lion_guild import LionGuild
|
||||
from core.data import CoreData
|
||||
from babel.translator import ctx_locale
|
||||
from gui.errors import RenderingException
|
||||
|
||||
from . import babel, logger
|
||||
from .data import TimerData
|
||||
@@ -45,6 +46,7 @@ class Timer:
|
||||
'_voice_update_lock',
|
||||
'_run_task',
|
||||
'_loop_task',
|
||||
'destroyed',
|
||||
)
|
||||
|
||||
break_name = _p('timer|stage:break|name', "BREAK")
|
||||
@@ -79,7 +81,10 @@ class Timer:
|
||||
# Main loop task. Should not be cancelled.
|
||||
self._loop_task = None
|
||||
|
||||
self.destroyed = False
|
||||
|
||||
def __repr__(self):
|
||||
# TODO: Add lock status and current state and stage
|
||||
return (
|
||||
"<Timer "
|
||||
f"channelid={self.data.channelid} "
|
||||
@@ -403,7 +408,7 @@ class Timer:
|
||||
"Remember to press {tick} to register your presence every stage.",
|
||||
len(needs_kick)
|
||||
), locale=self.locale.value).format(
|
||||
channel=self.channel.mention,
|
||||
channel=f"<#{self.data.channelid}>",
|
||||
mentions=', '.join(member.mention for member in needs_kick),
|
||||
tick=self.bot.config.emojis.tick
|
||||
)
|
||||
@@ -436,7 +441,7 @@ class Timer:
|
||||
if not stage:
|
||||
return
|
||||
|
||||
if not self.channel.permissions_for(self.guild.me).speak:
|
||||
if not self.channel or not self.channel.permissions_for(self.guild.me).speak:
|
||||
return
|
||||
|
||||
async with self.lguild.voice_lock:
|
||||
@@ -498,7 +503,7 @@ class Timer:
|
||||
"{channel} is now on **BREAK**! Take a rest, **FOCUS** starts {timestamp}"
|
||||
)
|
||||
stageline = t(lazy_stageline).format(
|
||||
channel=self.channel.mention,
|
||||
channel=f"<#{self.data.channelid}>",
|
||||
timestamp=f"<t:{int(stage.end.timestamp())}:R>"
|
||||
)
|
||||
return stageline
|
||||
@@ -555,29 +560,27 @@ class Timer:
|
||||
content = t(_p(
|
||||
'timer|status|stopped:auto',
|
||||
"Timer stopped! Join {channel} to start the timer."
|
||||
)).format(channel=self.channel.mention)
|
||||
embed = None
|
||||
)).format(channel=f"<#{self.data.channelid}>")
|
||||
else:
|
||||
content = t(_p(
|
||||
'timer|status|stopped:manual',
|
||||
"Timer stopped! Press `Start` to restart the timer."
|
||||
)).format(channel=self.channel.mention)
|
||||
embed = None
|
||||
|
||||
card = await get_timer_card(self.bot, self, stage)
|
||||
await card.render()
|
||||
)).format(channel=f"<#{self.data.channelid}>")
|
||||
|
||||
if (ui := self.status_view) is None:
|
||||
ui = self.status_view = TimerStatusUI(self.bot, self, self.channel)
|
||||
|
||||
await ui.refresh()
|
||||
|
||||
return MessageArgs(
|
||||
content=content,
|
||||
embed=embed,
|
||||
file=card.as_file(f"pomodoro_{self.data.channelid}.png"),
|
||||
view=ui
|
||||
)
|
||||
card = await get_timer_card(self.bot, self, stage)
|
||||
try:
|
||||
await card.render()
|
||||
file = card.as_file(f"pomodoro_{self.data.channelid}.png")
|
||||
args = MessageArgs(content=content, file=file, view=ui)
|
||||
except RenderingException:
|
||||
args = MessageArgs(content=content, view=ui)
|
||||
|
||||
return args
|
||||
|
||||
@log_wrap(action='Send Timer Status')
|
||||
async def send_status(self, delete_last=True, **kwargs):
|
||||
@@ -676,6 +679,7 @@ class Timer:
|
||||
if repost:
|
||||
await self.send_status(delete_last=False, with_notify=False)
|
||||
|
||||
@log_wrap(action='Update Channel Name')
|
||||
async def _update_channel_name(self):
|
||||
"""
|
||||
Submit a task to update the voice channel name.
|
||||
@@ -683,15 +687,19 @@ class Timer:
|
||||
Attempts to ensure that only one task is running at a time.
|
||||
Attempts to wait until the next viable channel update slot (via ratelimit).
|
||||
"""
|
||||
if self._voice_update_task and not self._voice_update_task.done():
|
||||
# Voice update request already submitted
|
||||
if self._voice_update_lock.locked():
|
||||
# Voice update is already running
|
||||
# Note that if channel editing takes a long time,
|
||||
# and the lock is waiting on that,
|
||||
# we may actually miss a channel update in this period.
|
||||
# Erring on the side of less ratelimits.
|
||||
return
|
||||
|
||||
async with self._voice_update_lock:
|
||||
if self._last_voice_update:
|
||||
to_wait = ((self._last_voice_update + timedelta(minutes=5)) - utc_now()).total_seconds()
|
||||
if to_wait > 0:
|
||||
self._voice_update_task = asyncio.create_task(asyncio.sleep(to_wait))
|
||||
self._voice_update_task = asyncio.create_task(asyncio.sleep(to_wait), name='timer-voice-wait')
|
||||
try:
|
||||
await self._voice_update_task
|
||||
except asyncio.CancelledError:
|
||||
@@ -706,8 +714,18 @@ class Timer:
|
||||
if new_name == self.channel.name:
|
||||
return
|
||||
|
||||
self._last_voice_update = utc_now()
|
||||
await self.channel.edit(name=self.channel_name)
|
||||
try:
|
||||
logger.debug(f"Requesting channel name update for timer {self}")
|
||||
await self.channel.edit(name=new_name)
|
||||
except discord.HTTPException:
|
||||
logger.warning(
|
||||
f"Voice channel name update failed for timer {self}",
|
||||
exc_info=True
|
||||
)
|
||||
finally:
|
||||
# Whether we fail or not, update ratelimit marker
|
||||
# (Repeatedly sending failing requests is even worse than normal ratelimits.)
|
||||
self._last_voice_update = utc_now()
|
||||
|
||||
@log_wrap(action="Stop Timer")
|
||||
async def stop(self, auto_restart=False):
|
||||
@@ -736,7 +754,12 @@ class Timer:
|
||||
if self._run_task and not self._run_task.done():
|
||||
self._run_task.cancel()
|
||||
channelid = self.data.channelid
|
||||
if self.channel:
|
||||
task = asyncio.create_task(
|
||||
self.channel.edit(name=self.data.pretty_name, reason="Reverting timer channel name")
|
||||
)
|
||||
await self.data.delete()
|
||||
self.destroyed = True
|
||||
if self.last_status_message:
|
||||
try:
|
||||
await self.last_status_message.delete()
|
||||
@@ -770,8 +793,8 @@ class Timer:
|
||||
to_next_stage = (current.end - utc_now()).total_seconds()
|
||||
|
||||
# TODO: Consider request rate and load
|
||||
if to_next_stage > 1 * 60 - drift:
|
||||
time_to_sleep = 1 * 60
|
||||
if to_next_stage > 5 * 60 - drift:
|
||||
time_to_sleep = 5 * 60
|
||||
else:
|
||||
time_to_sleep = to_next_stage
|
||||
|
||||
@@ -795,6 +818,7 @@ class Timer:
|
||||
if current.end < utc_now():
|
||||
self._state = self.current_stage
|
||||
task = asyncio.create_task(self.notify_change_stage(current, self._state))
|
||||
background_tasks.add(task)
|
||||
task.add_done_callback(background_tasks.discard)
|
||||
current = self._state
|
||||
elif self.members:
|
||||
|
||||
@@ -37,6 +37,23 @@ class TimerOptionsUI(MessageUI):
|
||||
self.timer = timer
|
||||
self.role = role
|
||||
|
||||
async def interaction_check(self, interaction: discord.Interaction):
|
||||
if self.timer.destroyed:
|
||||
t = self.bot.translator.t
|
||||
error = t(_p(
|
||||
'ui:timer_options|error:timer_destroyed',
|
||||
"This timer no longer exists! Closing option menu."
|
||||
))
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
description=error
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
await self.quit()
|
||||
return False
|
||||
else:
|
||||
return await super().interaction_check(interaction)
|
||||
|
||||
@button(label="EDIT_PLACEHOLDER", style=ButtonStyle.blurple)
|
||||
async def edit_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
import datetime
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
import discord
|
||||
from discord.ext import commands as cmds
|
||||
@@ -16,6 +17,8 @@ from utils.ui import ChoicedEnum, Transformed
|
||||
from utils.lib import utc_now, replace_multiple
|
||||
from utils.ratelimits import Bucket, limit_concurrency
|
||||
from utils.data import TemporaryTable
|
||||
from modules.economy.cog import Economy
|
||||
from modules.economy.data import TransactionType
|
||||
|
||||
|
||||
from . import babel, logger
|
||||
@@ -126,6 +129,9 @@ class RankCog(LionCog):
|
||||
# pop the guild whenever the season is updated or the rank type changes.
|
||||
self._member_ranks = {}
|
||||
|
||||
# Weakly referenced Locks for each guild to serialise rank actions
|
||||
self._rank_locks: dict[int, asyncio.Lock] = WeakValueDictionary()
|
||||
|
||||
async def cog_load(self):
|
||||
await self.data.init()
|
||||
|
||||
@@ -136,6 +142,13 @@ class RankCog(LionCog):
|
||||
configcog = self.bot.get_cog('ConfigCog')
|
||||
self.crossload_group(self.configure_group, configcog.configure_group)
|
||||
|
||||
def ranklock(self, guildid):
|
||||
lock = self._rank_locks.get(guildid, None)
|
||||
if lock is None:
|
||||
lock = self._rank_locks[guildid] = asyncio.Lock()
|
||||
logger.debug(f"Getting rank lock for guild <guildid: {guildid}> (locked: {lock.locked()})")
|
||||
return lock
|
||||
|
||||
# ---------- Event handlers ----------
|
||||
# season_start setting event handler.. clears the guild season rank cache
|
||||
@LionCog.listener('on_guildset_season_start')
|
||||
@@ -255,50 +268,98 @@ class RankCog(LionCog):
|
||||
"""
|
||||
Handle batch of completed message sessions.
|
||||
"""
|
||||
tasks = []
|
||||
# TODO: Thread safety
|
||||
# TODO: Locking between refresh and individual updates
|
||||
for guildid, userid, messages, guild_xp in session_data:
|
||||
lguild = await self.bot.core.lions.fetch_guild(guildid)
|
||||
rank_type = lguild.config.get('rank_type').value
|
||||
if rank_type in (RankType.MESSAGE, RankType.XP):
|
||||
if (_members := self._member_ranks.get(guildid, None)) is not None and userid in _members:
|
||||
session_rank = _members[userid]
|
||||
session_rank.stat += messages if (rank_type is RankType.MESSAGE) else guild_xp
|
||||
else:
|
||||
session_rank = await self.get_member_rank(guildid, userid)
|
||||
async with self.ranklock(guildid):
|
||||
if (_members := self._member_ranks.get(guildid, None)) is not None and userid in _members:
|
||||
session_rank = _members[userid]
|
||||
session_rank.stat += messages if (rank_type is RankType.MESSAGE) else guild_xp
|
||||
else:
|
||||
session_rank = await self.get_member_rank(guildid, userid)
|
||||
|
||||
if session_rank.next_rank is not None and session_rank.stat > session_rank.next_rank.required:
|
||||
tasks.append(asyncio.create_task(self.update_rank(session_rank)))
|
||||
else:
|
||||
tasks.append(asyncio.create_task(self._role_check(session_rank)))
|
||||
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks)
|
||||
if session_rank.next_rank is not None and session_rank.stat > session_rank.next_rank.required:
|
||||
task = asyncio.create_task(self.update_rank(session_rank), name='update-message-rank')
|
||||
else:
|
||||
task = asyncio.create_task(self._role_check(session_rank), name='rank-role-check')
|
||||
await task
|
||||
|
||||
async def _role_check(self, session_rank: SeasonRank):
|
||||
guild = self.bot.get_guild(session_rank.guildid)
|
||||
member = guild.get_member(session_rank.userid)
|
||||
crank = session_rank.current_rank
|
||||
roleid = crank.roleid if crank else None
|
||||
last_roleid = session_rank.rankrow.last_roleid
|
||||
if guild is not None and member is not None and roleid != last_roleid:
|
||||
new_role = guild.get_role(roleid) if roleid else None
|
||||
last_role = guild.get_role(last_roleid) if last_roleid else None
|
||||
new_last_roleid = last_roleid
|
||||
if guild.me.guild_permissions.manage_roles:
|
||||
try:
|
||||
if last_role and last_role.is_assignable():
|
||||
await member.remove_roles(last_role)
|
||||
new_last_roleid = None
|
||||
if new_role and new_role.is_assignable():
|
||||
await member.add_roles(new_role)
|
||||
new_last_roleid = roleid
|
||||
except discord.HTTPClient:
|
||||
pass
|
||||
if new_last_roleid != last_roleid:
|
||||
await session_rank.rankrow.update(last_roleid=new_last_roleid)
|
||||
"""
|
||||
Update the member's rank roles, if required.
|
||||
"""
|
||||
guildid = session_rank.guildid
|
||||
guild = self.bot.get_guild(guildid)
|
||||
|
||||
userid = session_rank.userid
|
||||
member = guild.get_member(userid)
|
||||
|
||||
if guild is not None and member is not None and guild.me.guild_permissions.manage_roles:
|
||||
ranks = await self.get_guild_ranks(guildid)
|
||||
|
||||
crank = session_rank.current_rank
|
||||
current_roleid = crank.roleid if crank else None
|
||||
|
||||
# First gather rank roleids, note that the last_roleid is an 'honourary' roleid
|
||||
last_roleid = session_rank.rankrow.last_roleid
|
||||
rank_roleids = {rank.roleid for rank in ranks}
|
||||
rank_roleids.add(last_roleid)
|
||||
|
||||
# Gather member roleids
|
||||
mem_roleids = {role.id: role for role in member.roles}
|
||||
|
||||
# Calculate diffs
|
||||
to_add = guild.get_role(current_roleid) if (current_roleid not in mem_roleids) else None
|
||||
to_rm = [
|
||||
role for roleid, role in mem_roleids.items()
|
||||
if roleid in rank_roleids and roleid != current_roleid
|
||||
]
|
||||
|
||||
# Now update roles
|
||||
new_last_roleid = last_roleid
|
||||
|
||||
# TODO: Event log here, including errors
|
||||
to_rm = [role for role in to_rm if role.is_assignable()]
|
||||
if to_rm:
|
||||
try:
|
||||
await member.remove_roles(
|
||||
*to_rm,
|
||||
reason="Removing Old Rank Roles",
|
||||
atomic=True
|
||||
)
|
||||
roleids = ', '.join(str(role.id) for role in to_rm)
|
||||
logger.info(
|
||||
f"Removed old rank roles from <uid:{userid}> in <gid:{guildid}>: {roleids}"
|
||||
)
|
||||
new_last_roleid = None
|
||||
except discord.HTTPException:
|
||||
logger.warning(
|
||||
f"Unexpected error removing old rank roles from <uid:{member.id}> in <gid:{guild.id}>: {to_rm}",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
if to_add and to_add.is_assignable():
|
||||
try:
|
||||
await member.add_roles(
|
||||
to_add,
|
||||
reason="Rewarding Activity Rank",
|
||||
atomic=True
|
||||
)
|
||||
logger.info(
|
||||
f"Rewarded rank role <rid:{to_add.id}> to <uid:{userid}> in <gid:{guildid}>."
|
||||
)
|
||||
new_last_roleid = to_add.id
|
||||
except discord.HTTPException:
|
||||
logger.warning(
|
||||
f"Unexpected error giving <uid:{userid}> in <gid:{guildid}> their rank role <rid:{to_add.id}>",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
if new_last_roleid != last_roleid:
|
||||
await session_rank.rankrow.update(last_roleid=new_last_roleid)
|
||||
|
||||
@log_wrap(action="Update Rank")
|
||||
async def update_rank(self, session_rank):
|
||||
# Identify target rank
|
||||
guildid = session_rank.guildid
|
||||
@@ -326,22 +387,61 @@ class RankCog(LionCog):
|
||||
if member is None:
|
||||
return
|
||||
|
||||
new_role = guild.get_role(new_rank.roleid)
|
||||
if last_roleid := session_rank.rankrow.last_roleid:
|
||||
last_role = guild.get_role(last_roleid)
|
||||
else:
|
||||
last_role = None
|
||||
last_roleid = session_rank.rankrow.last_roleid
|
||||
|
||||
# Update ranks
|
||||
if guild.me.guild_permissions.manage_roles:
|
||||
try:
|
||||
if last_role and last_role.is_assignable():
|
||||
await member.remove_roles(last_role)
|
||||
# First gather rank roleids, note that the last_roleid is an 'honourary' roleid
|
||||
rank_roleids = {rank.roleid for rank in ranks}
|
||||
rank_roleids.add(last_roleid)
|
||||
|
||||
# Gather member roleids
|
||||
mem_roleids = {role.id: role for role in member.roles}
|
||||
|
||||
# Calculate diffs
|
||||
to_add = guild.get_role(new_rank.roleid) if (new_rank.roleid not in mem_roleids) else None
|
||||
to_rm = [
|
||||
role for roleid, role in mem_roleids.items()
|
||||
if roleid in rank_roleids and roleid != new_rank.roleid
|
||||
]
|
||||
|
||||
# Now update roles
|
||||
# TODO: Event log here, including errors
|
||||
to_rm = [role for role in to_rm if role.is_assignable()]
|
||||
if to_rm:
|
||||
try:
|
||||
await member.remove_roles(
|
||||
*to_rm,
|
||||
reason="Removing Old Rank Roles",
|
||||
atomic=True
|
||||
)
|
||||
roleids = ', '.join(str(role.id) for role in to_rm)
|
||||
logger.info(
|
||||
f"Removed old rank roles from <uid:{userid}> in <gid:{guildid}>: {roleids}"
|
||||
)
|
||||
last_roleid = None
|
||||
if new_role and new_role.is_assignable():
|
||||
await member.add_roles(new_role)
|
||||
last_roleid = new_role.id
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
except discord.HTTPException:
|
||||
logger.warning(
|
||||
f"Unexpected error removing old rank roles from <uid:{member.id}> in <gid:{guild.id}>: {to_rm}",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
if to_add and to_add.is_assignable():
|
||||
try:
|
||||
await member.add_roles(
|
||||
to_add,
|
||||
reason="Rewarding Activity Rank",
|
||||
atomic=True
|
||||
)
|
||||
logger.info(
|
||||
f"Rewarded rank role <rid:{to_add.id}> to <uid:{userid}> in <gid:{guildid}>."
|
||||
)
|
||||
last_roleid=to_add.id
|
||||
except discord.HTTPException:
|
||||
logger.warning(
|
||||
f"Unexpected error giving <uid:{userid}> in <gid:{guildid}> their rank role <rid:{to_add.id}>",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Update MemberRank row
|
||||
column = {
|
||||
@@ -357,6 +457,18 @@ class RankCog(LionCog):
|
||||
session_rank.current_rank = new_rank
|
||||
session_rank.next_rank = next((rank for rank in ranks if rank.required > new_rank.required), None)
|
||||
|
||||
# Provide economy reward if required
|
||||
if new_rank.reward:
|
||||
economy: Economy = self.bot.get_cog('Economy')
|
||||
await economy.data.Transaction.execute_transaction(
|
||||
TransactionType.OTHER,
|
||||
guildid=guildid,
|
||||
actorid=guild.me.id,
|
||||
from_account=None,
|
||||
to_account=userid,
|
||||
amount=new_rank.reward
|
||||
)
|
||||
|
||||
# Send notification
|
||||
await self._notify_rank_update(guildid, userid, new_rank)
|
||||
|
||||
@@ -415,7 +527,7 @@ class RankCog(LionCog):
|
||||
required = format_stat_range(rank_type, rank.required, short=False)
|
||||
|
||||
key_map = {
|
||||
'{role_name}': role.name,
|
||||
'{role_name}': role.name if role else 'Unknown',
|
||||
'{guild_name}': guild.name,
|
||||
'{user_name}': member.name,
|
||||
'{role_id}': role.id,
|
||||
@@ -427,10 +539,8 @@ class RankCog(LionCog):
|
||||
}
|
||||
return key_map
|
||||
|
||||
@log_wrap(action="Voice Rank Hook")
|
||||
async def on_voice_session_complete(self, *session_data):
|
||||
tasks = []
|
||||
# TODO: Thread safety
|
||||
# TODO: Locking between refresh and individual updates
|
||||
for guildid, userid, duration, guild_xp in session_data:
|
||||
lguild = await self.bot.core.lions.fetch_guild(guildid)
|
||||
unranked_role_setting = await self.bot.get_cog('StatsCog').settings.UnrankedRoles.get(guildid)
|
||||
@@ -441,27 +551,28 @@ class RankCog(LionCog):
|
||||
continue
|
||||
rank_type = lguild.config.get('rank_type').value
|
||||
if rank_type in (RankType.VOICE,):
|
||||
if (_members := self._member_ranks.get(guildid, None)) is not None and userid in _members:
|
||||
session_rank = _members[userid]
|
||||
# TODO: Temporary measure
|
||||
season_start = lguild.config.get('season_start').value or datetime.datetime(1970, 1, 1)
|
||||
stat_data = self.bot.get_cog('StatsCog').data
|
||||
session_rank.stat = (await stat_data.VoiceSessionStats.study_times_since(
|
||||
guildid, userid, season_start)
|
||||
)[0]
|
||||
# session_rank.stat += duration if (rank_type is RankType.VOICE) else guild_xp
|
||||
else:
|
||||
session_rank = await self.get_member_rank(guildid, userid)
|
||||
async with self.ranklock(guildid):
|
||||
if (_members := self._member_ranks.get(guildid, None)) is not None and userid in _members:
|
||||
session_rank = _members[userid]
|
||||
# TODO: Temporary measure
|
||||
season_start = lguild.config.get('season_start').value or datetime.datetime(1970, 1, 1)
|
||||
stat_data = self.bot.get_cog('StatsCog').data
|
||||
session_rank.stat = (await stat_data.VoiceSessionStats.study_times_since(
|
||||
guildid, userid, season_start)
|
||||
)[0]
|
||||
# session_rank.stat += duration if (rank_type is RankType.VOICE) else guild_xp
|
||||
else:
|
||||
session_rank = await self.get_member_rank(guildid, userid)
|
||||
|
||||
if session_rank.next_rank is not None and session_rank.stat > session_rank.next_rank.required:
|
||||
tasks.append(asyncio.create_task(self.update_rank(session_rank)))
|
||||
else:
|
||||
tasks.append(asyncio.create_task(self._role_check(session_rank)))
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks)
|
||||
if session_rank.next_rank is not None and session_rank.stat > session_rank.next_rank.required:
|
||||
task = asyncio.create_task(self.update_rank(session_rank), name='voice-rank-update')
|
||||
else:
|
||||
task = asyncio.create_task(self._role_check(session_rank), name='voice-role-check')
|
||||
|
||||
async def on_xp_update(self, *xp_data):
|
||||
...
|
||||
# Currently no-op since xp is given purely by message stats
|
||||
# Implement if xp ever becomes a combination of message and voice stats
|
||||
pass
|
||||
|
||||
@log_wrap(action='interactive rank refresh')
|
||||
async def interactive_rank_refresh(self, interaction: discord.Interaction, guild: discord.Guild):
|
||||
@@ -470,9 +581,10 @@ class RankCog(LionCog):
|
||||
"""
|
||||
t = self.bot.translator.t
|
||||
if not interaction.response.is_done():
|
||||
await interaction.response.defer(thinking=True, ephemeral=False)
|
||||
await interaction.response.defer(thinking=False)
|
||||
ui = RankRefreshUI(self.bot, guild, callerid=interaction.user.id, timeout=None)
|
||||
await ui.run(interaction)
|
||||
await ui.send(interaction.channel)
|
||||
ui.start()
|
||||
|
||||
# Retrieve fresh rank roles
|
||||
ranks = await self.get_guild_ranks(guild.id, refresh=True)
|
||||
@@ -481,7 +593,15 @@ class RankCog(LionCog):
|
||||
|
||||
# Ensure guild is chunked
|
||||
if not guild.chunked:
|
||||
members = await guild.chunk()
|
||||
try:
|
||||
members = await asyncio.wait_for(guild.chunk(), timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
error = t(_p(
|
||||
'rank_refresh|error:cannot_chunk|desc',
|
||||
"Could not retrieve member list from Discord. Please try again later."
|
||||
))
|
||||
await ui.set_error(error)
|
||||
return
|
||||
else:
|
||||
members = guild.members
|
||||
ui.stage_members = True
|
||||
@@ -638,18 +758,18 @@ class RankCog(LionCog):
|
||||
# Save correct member ranks and given roles to data
|
||||
# First clear the member rank data entirely
|
||||
await self.data.MemberRank.table.delete_where(guildid=guild.id)
|
||||
column = self._get_rankid_column(rank_type)
|
||||
values = [
|
||||
(guild.id, memberid, rank.rankid, rank.roleid)
|
||||
for memberid, rank in true_member_ranks.items()
|
||||
]
|
||||
await self.data.MemberRank.table.insert_many(
|
||||
('guildid', 'userid', column, 'last_roleid'),
|
||||
*values
|
||||
)
|
||||
if true_member_ranks:
|
||||
column = self._get_rankid_column(rank_type)
|
||||
values = [
|
||||
(guild.id, memberid, rank.rankid, rank.roleid)
|
||||
for memberid, rank in true_member_ranks.items()
|
||||
]
|
||||
await self.data.MemberRank.table.insert_many(
|
||||
('guildid', 'userid', column, 'last_roleid'),
|
||||
*values
|
||||
)
|
||||
self.flush_guild_ranks(guild.id)
|
||||
await ui.set_done()
|
||||
await ui.wait()
|
||||
|
||||
# ---------- Commands ----------
|
||||
@cmds.hybrid_command(name=_p('cmd:ranks', "ranks"))
|
||||
@@ -671,7 +791,7 @@ class RankCog(LionCog):
|
||||
await ui.wait()
|
||||
else:
|
||||
await ui.reload()
|
||||
msg = await ui.make_message()
|
||||
msg = await ui.make_message(show_note=False)
|
||||
await ctx.reply(
|
||||
**msg.send_args,
|
||||
ephemeral=True
|
||||
@@ -740,7 +860,7 @@ class RankCog(LionCog):
|
||||
lines = []
|
||||
if rank_type_setting in modified:
|
||||
lines.append(rank_type_setting.update_message)
|
||||
if dm_ranks or rank_channel:
|
||||
if (dm_ranks is not None) or (rank_channel is not None):
|
||||
if dm_ranks_setting.value:
|
||||
if rank_channel_setting.value:
|
||||
notif_string = t(_p(
|
||||
|
||||
@@ -6,11 +6,13 @@ from discord.ui.select import select, Select, SelectOption, RoleSelect
|
||||
from discord.ui.button import button, Button, ButtonStyle
|
||||
|
||||
from meta import conf, LionBot
|
||||
from meta.errors import ResponseTimedOut
|
||||
from core.data import RankType
|
||||
from data import ORDER
|
||||
|
||||
from utils.ui import MessageUI
|
||||
from utils.ui import MessageUI, Confirm
|
||||
from utils.lib import MessageArgs
|
||||
from wards import equippable_role
|
||||
from babel.translator import ctx_translator
|
||||
|
||||
from .. import babel, logger
|
||||
@@ -30,6 +32,7 @@ class RankOverviewUI(MessageUI):
|
||||
self.bot = bot
|
||||
self.guild = guild
|
||||
self.guildid = guild.id
|
||||
self.cog = bot.get_cog('RankCog')
|
||||
|
||||
self.lguild = None
|
||||
|
||||
@@ -98,8 +101,8 @@ class RankOverviewUI(MessageUI):
|
||||
Refresh the current ranks,
|
||||
ensuring that all members have the correct rank.
|
||||
"""
|
||||
cog = self.bot.get_cog('RankCog')
|
||||
await cog.interactive_rank_refresh(press, self.guild)
|
||||
async with self.cog.ranklock(self.guild.id):
|
||||
await self.cog.interactive_rank_refresh(press, self.guild)
|
||||
|
||||
async def refresh_button_refresh(self):
|
||||
self.refresh_button.label = self.bot.translator.t(_p(
|
||||
@@ -107,15 +110,38 @@ class RankOverviewUI(MessageUI):
|
||||
"Refresh Member Ranks"
|
||||
))
|
||||
|
||||
@button(label="CLEAR_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
|
||||
@button(label="CLEAR_BUTTON_PLACEHOLDER", style=ButtonStyle.red)
|
||||
async def clear_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Clear the rank list.
|
||||
"""
|
||||
await self.rank_model.table.delete_where(guildid=self.guildid)
|
||||
self.bot.get_cog('RankCog').flush_guild_ranks(self.guild.id)
|
||||
self.ranks = []
|
||||
await self.redraw()
|
||||
# Confirm deletion
|
||||
t = self.bot.translator.t
|
||||
confirm_msg = t(_p(
|
||||
'ui:rank_overview|button:clear|confirm',
|
||||
"Are you sure you want to **delete all activity ranks** in this server?"
|
||||
))
|
||||
confirmui = Confirm(confirm_msg, self._callerid)
|
||||
confirmui.confirm_button.label = t(_p(
|
||||
'ui:rank_overview|button:clear|confirm|button:yes',
|
||||
"Yes, clear ranks"
|
||||
))
|
||||
confirmui.confirm_button.style = ButtonStyle.red
|
||||
confirmui.cancel_button.style = ButtonStyle.green
|
||||
confirmui.cancel_button.label = t(_p(
|
||||
'ui:rank_overview|button:clear|confirm|button:no',
|
||||
"Cancel"
|
||||
))
|
||||
try:
|
||||
result = await confirmui.ask(press, ephemeral=True)
|
||||
except ResponseTimedOut:
|
||||
result = False
|
||||
if result:
|
||||
async with self.cog.ranklock(self.guild.id):
|
||||
await self.rank_model.table.delete_where(guildid=self.guildid)
|
||||
self.cog.flush_guild_ranks(self.guild.id)
|
||||
self.ranks = []
|
||||
await self.redraw()
|
||||
|
||||
async def clear_button_refresh(self):
|
||||
self.clear_button.label = self.bot.translator.t(_p(
|
||||
@@ -160,25 +186,11 @@ class RankOverviewUI(MessageUI):
|
||||
or edit an existing rank,
|
||||
or throw an error if the role is @everyone or not manageable by the client.
|
||||
"""
|
||||
|
||||
role: discord.Role = selected.values[0]
|
||||
if role >= selection.user.top_role:
|
||||
# Do not allow user to manage a role above their own top role
|
||||
t = self.bot.translator.t
|
||||
error = t(_p(
|
||||
'ui:rank_overview|menu:roles|error:above_caller',
|
||||
"You have insufficient permissions to assign {mention} as a rank role! "
|
||||
"You may only manage roles below your top role."
|
||||
)).format(mention=role.mention)
|
||||
embed = discord.Embed(
|
||||
title=t(_p(
|
||||
'ui:rank_overview|menu:roles|error:above_caller|title',
|
||||
"Insufficient permissions!"
|
||||
)),
|
||||
description=error,
|
||||
colour=discord.Colour.brand_red()
|
||||
)
|
||||
await selection.response.send_message(embed=embed, ephemeral=True)
|
||||
elif role.is_assignable():
|
||||
|
||||
if role.is_assignable():
|
||||
# Create or edit the selected role
|
||||
existing = next((rank for rank in self.ranks if rank.roleid == role.id), None)
|
||||
if existing:
|
||||
# Display and edit the given role
|
||||
@@ -191,6 +203,8 @@ class RankOverviewUI(MessageUI):
|
||||
)
|
||||
else:
|
||||
# Create new rank based on role
|
||||
# Need to check the calling author has authority to manage this role
|
||||
await equippable_role(self.bot, role, selection.user)
|
||||
await RankEditor.create_rank(
|
||||
selection,
|
||||
self.rank_type,
|
||||
@@ -324,7 +338,7 @@ class RankOverviewUI(MessageUI):
|
||||
string = f"{start} msgs"
|
||||
return string
|
||||
|
||||
async def make_message(self) -> MessageArgs:
|
||||
async def make_message(self, show_note=True) -> MessageArgs:
|
||||
t = self.bot.translator.t
|
||||
|
||||
if self.ranks:
|
||||
@@ -385,6 +399,40 @@ class RankOverviewUI(MessageUI):
|
||||
title=title,
|
||||
description=desc
|
||||
)
|
||||
if show_note:
|
||||
# Add note about season start
|
||||
note_name = t(_p(
|
||||
'ui:rank_overview|embed|field:note|name',
|
||||
"Note"
|
||||
))
|
||||
season_start = self.lguild.data.season_start
|
||||
if season_start:
|
||||
season_str = t(_p(
|
||||
'ui:rank_overview|embed|field:note|value:with_season',
|
||||
"Ranks are determined by activity since {timestamp}."
|
||||
)).format(
|
||||
timestamp=discord.utils.format_dt(season_start)
|
||||
)
|
||||
else:
|
||||
season_str = t(_p(
|
||||
'ui:rank_overview|embed|field:note|value:without_season',
|
||||
"Ranks are determined by *all-time* statistics.\n"
|
||||
"To reward ranks from a later time (e.g. to have monthly/quarterly/yearly ranks) "
|
||||
"set the `season_start` with {stats_cmd}"
|
||||
)).format(stats_cmd=self.bot.core.mention_cmd('configure statistics'))
|
||||
if self.rank_type is RankType.VOICE:
|
||||
addendum = t(_p(
|
||||
'ui:rank_overview|embed|field:note|value|voice_addendum',
|
||||
"Also note that ranks will only be updated when a member leaves a tracked voice channel! "
|
||||
"Use the **Refresh Member Ranks** button below to update all members manually."
|
||||
))
|
||||
season_str = '\n'.join((season_str, addendum))
|
||||
embed.add_field(
|
||||
name=note_name,
|
||||
value=season_str,
|
||||
inline=False
|
||||
)
|
||||
|
||||
return MessageArgs(embed=embed)
|
||||
|
||||
async def refresh_layout(self):
|
||||
|
||||
@@ -7,6 +7,7 @@ from discord.ui.button import button, Button, ButtonStyle
|
||||
|
||||
from meta import conf, LionBot
|
||||
from core.data import RankType
|
||||
from wards import equippable_role
|
||||
|
||||
from utils.ui import MessageUI, AButton, AsComponents
|
||||
from utils.lib import MessageArgs, replace_multiple
|
||||
@@ -112,6 +113,7 @@ class RankPreviewUI(MessageUI):
|
||||
await submit.response.defer(thinking=False)
|
||||
if self.parent is not None:
|
||||
asyncio.create_task(self.parent.refresh())
|
||||
self.bot.get_cog('RankCog').flush_guild_ranks(self.guild.id)
|
||||
await self.refresh()
|
||||
|
||||
@button(label="DELETE_PLACEHOLDER", style=ButtonStyle.red)
|
||||
@@ -130,6 +132,7 @@ class RankPreviewUI(MessageUI):
|
||||
role = None
|
||||
|
||||
await self.rank.delete()
|
||||
self.bot.get_cog('RankCog').flush_guild_ranks(self.guild.id)
|
||||
|
||||
mention = role.mention if role else str(self.rank.roleid)
|
||||
|
||||
@@ -212,25 +215,13 @@ class RankPreviewUI(MessageUI):
|
||||
role: discord.Role = selected.values[0]
|
||||
await selection.response.defer(thinking=True, ephemeral=True)
|
||||
|
||||
if role >= selection.user.top_role:
|
||||
# Do not allow user to manage a role above their own top role
|
||||
error = t(_p(
|
||||
'ui:rank_preview|menu:roles|error:above_caller',
|
||||
"You have insufficient permissions to assign {mention} as a rank role! "
|
||||
"You may only manage roles below your top role."
|
||||
))
|
||||
embed = discord.Embed(
|
||||
title=t(_p(
|
||||
'ui:rank_preview|menu:roles|error:above_caller|title',
|
||||
"Insufficient permissions!"
|
||||
)),
|
||||
description=error,
|
||||
colour=discord.Colour.brand_red()
|
||||
)
|
||||
await selection.response.send_message(embed=embed, ephemeral=True)
|
||||
elif role.is_assignable():
|
||||
if role.is_assignable():
|
||||
# Update the rank role
|
||||
# Generic permission check for the new role
|
||||
await equippable_role(self.bot, role, selection.user)
|
||||
|
||||
await self.rank.update(roleid=role.id)
|
||||
self.bot.get_cog('RankCog').flush_guild_ranks(self.guild.id)
|
||||
if self.parent is not None and not self.parent.is_finished():
|
||||
asyncio.create_task(self.parent.refresh())
|
||||
await self.refresh(thinking=selection)
|
||||
|
||||
@@ -64,9 +64,12 @@ class RankRefreshUI(MessageUI):
|
||||
def poke(self):
|
||||
self._wakeup.set()
|
||||
|
||||
def start(self):
|
||||
self._loop_task = asyncio.create_task(self._refresh_loop(), name='Rank RefreshUI Monitor')
|
||||
|
||||
async def run(self, *args, **kwargs):
|
||||
await super().run(*args, **kwargs)
|
||||
self._loop_task = asyncio.create_task(self._refresh_loop(), name='refresh ui loop')
|
||||
self.start()
|
||||
|
||||
async def cleanup(self):
|
||||
if self._loop_task and not self._loop_task.done():
|
||||
@@ -199,10 +202,11 @@ class RankRefreshUI(MessageUI):
|
||||
))
|
||||
value = t(_p(
|
||||
'ui:refresh_ranks|embed|field:remove|value',
|
||||
"0 {progress} {total}"
|
||||
"{progress} {done}/{total} removed"
|
||||
)).format(
|
||||
progress=self.progress_bar(self.removed, 0, self.to_remove),
|
||||
total=self.to_remove,
|
||||
done=self.removed,
|
||||
)
|
||||
embed.add_field(name=name, value=value, inline=False)
|
||||
else:
|
||||
@@ -221,10 +225,11 @@ class RankRefreshUI(MessageUI):
|
||||
))
|
||||
value = t(_p(
|
||||
'ui:refresh_ranks|embed|field:add|value',
|
||||
"0 {progress} {total}"
|
||||
"{progress} {done}/{total} given"
|
||||
)).format(
|
||||
progress=self.progress_bar(self.added, 0, self.to_add),
|
||||
total=self.to_add,
|
||||
done=self.added,
|
||||
)
|
||||
embed.add_field(name=name, value=value, inline=False)
|
||||
else:
|
||||
|
||||
@@ -12,158 +12,36 @@ Max 25 reminders (propagating Discord restriction)
|
||||
"""
|
||||
from typing import Optional
|
||||
import datetime as dt
|
||||
from cachetools import TTLCache, LRUCache
|
||||
from cachetools import TTLCache
|
||||
|
||||
import discord
|
||||
from discord.ext import commands as cmds
|
||||
from discord import app_commands as appcmds
|
||||
from discord.app_commands import Transform
|
||||
from discord.ui.select import select, SelectOption
|
||||
from dateutil.parser import parse, ParserError
|
||||
|
||||
from data import RowModel, Registry, WeakCache
|
||||
from data.queries import ORDER
|
||||
from data.columns import Integer, String, Timestamp, Bool
|
||||
|
||||
from meta import LionBot, LionCog, LionContext
|
||||
from meta.errors import UserInputError
|
||||
from meta.app import shard_talk, appname_from_shard
|
||||
from meta.logger import log_wrap, logging_context, set_logging_context
|
||||
from meta.logger import log_wrap, set_logging_context
|
||||
|
||||
from babel import ctx_translator, ctx_locale
|
||||
|
||||
from utils.lib import parse_duration, utc_now, strfdur, error_embed
|
||||
from utils.lib import parse_duration, utc_now, strfdur, error_embed, check_dm
|
||||
from utils.monitor import TaskMonitor
|
||||
from utils.transformers import DurationTransformer
|
||||
from utils.ui import LeoUI, AButton, AsComponents
|
||||
from utils.ui import AButton, AsComponents
|
||||
from utils.ratelimits import Bucket
|
||||
|
||||
from . import babel, logger
|
||||
from .data import ReminderData
|
||||
from .ui import ReminderList
|
||||
|
||||
_, _p, _np = babel._, babel._p, babel._np
|
||||
|
||||
|
||||
class ReminderData(Registry, name='reminders'):
|
||||
class Reminder(RowModel):
|
||||
"""
|
||||
Model representing a single reminder.
|
||||
Since reminders are likely to change across shards,
|
||||
does not use an explicit reference cache.
|
||||
|
||||
Schema
|
||||
------
|
||||
CREATE TABLE reminders(
|
||||
reminderid SERIAL PRIMARY KEY,
|
||||
userid BIGINT NOT NULL REFERENCES user_config(userid) ON DELETE CASCADE,
|
||||
remind_at TIMESTAMPTZ NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
message_link TEXT,
|
||||
interval INTEGER,
|
||||
created_at TIMESTAMP DEFAULT (now() at time zone 'utc'),
|
||||
title TEXT,
|
||||
footer TEXT
|
||||
);
|
||||
CREATE INDEX reminder_users ON reminders (userid);
|
||||
"""
|
||||
_tablename_ = 'reminders'
|
||||
|
||||
reminderid = Integer(primary=True)
|
||||
|
||||
userid = Integer() # User which created the reminder
|
||||
remind_at = Timestamp() # Time when the reminder should be executed
|
||||
content = String() # Content the user gave us to remind them
|
||||
message_link = String() # Link to original confirmation message, for context
|
||||
interval = Integer() # Repeat interval, if applicable
|
||||
created_at = Timestamp() # Time when this reminder was originally created
|
||||
title = String() # Title of the final reminder embed, only set in automated reminders
|
||||
footer = String() # Footer of the final reminder embed, only set in automated reminders
|
||||
failed = Bool() # Whether the reminder was already attempted and failed
|
||||
|
||||
@property
|
||||
def timestamp(self) -> int:
|
||||
"""
|
||||
Time when this reminder should be executed (next) as an integer timestamp.
|
||||
"""
|
||||
return int(self.remind_at.timestamp())
|
||||
|
||||
@property
|
||||
def embed(self) -> discord.Embed:
|
||||
t = ctx_translator.get().t
|
||||
|
||||
embed = discord.Embed(
|
||||
title=self.title or t(_p('reminder|embed', "You asked me to remind you!")),
|
||||
colour=discord.Colour.orange(),
|
||||
description=self.content,
|
||||
timestamp=self.remind_at
|
||||
)
|
||||
|
||||
if self.message_link:
|
||||
embed.add_field(
|
||||
name=t(_p('reminder|embed', "Context?")),
|
||||
value="[{click}]({link})".format(
|
||||
click=t(_p('reminder|embed', "Click Here")),
|
||||
link=self.message_link
|
||||
)
|
||||
)
|
||||
|
||||
if self.interval:
|
||||
embed.add_field(
|
||||
name=t(_p('reminder|embed', "Next reminder")),
|
||||
value=f"<t:{self.timestamp + self.interval}:R>"
|
||||
)
|
||||
|
||||
if self.footer:
|
||||
embed.set_footer(text=self.footer)
|
||||
|
||||
return embed
|
||||
|
||||
@property
|
||||
def formatted(self):
|
||||
"""
|
||||
Single-line string format for the reminder, intended for an embed.
|
||||
"""
|
||||
t = ctx_translator.get().t
|
||||
content = self.content
|
||||
trunc_content = content[:50] + '...' * (len(content) > 50)
|
||||
|
||||
if interval := self.interval:
|
||||
if not interval % (24 * 60 * 60):
|
||||
# Exact day case
|
||||
days = interval // (24 * 60 * 60)
|
||||
repeat = t(_np(
|
||||
'reminder|formatted|interval',
|
||||
"Every day",
|
||||
"Every `{days}` days",
|
||||
days
|
||||
)).format(days=days)
|
||||
elif not interval % (60 * 60):
|
||||
# Exact hour case
|
||||
hours = interval // (60 * 60)
|
||||
repeat = t(_np(
|
||||
'reminder|formatted|interval',
|
||||
"Every hour",
|
||||
"Every `{hours}` hours",
|
||||
hours
|
||||
)).format(hours=hours)
|
||||
else:
|
||||
# Inexact interval, e.g 10m or 1h 10m.
|
||||
# Use short duration format
|
||||
repeat = t(_p(
|
||||
'reminder|formatted|interval',
|
||||
"Every `{duration}`"
|
||||
)).format(duration=strfdur(interval))
|
||||
|
||||
repeat = f"({repeat})"
|
||||
else:
|
||||
repeat = ""
|
||||
|
||||
return "<t:{timestamp}:R>, [{content}]({jump_link}) {repeat}".format(
|
||||
jump_link=self.message_link,
|
||||
content=trunc_content,
|
||||
timestamp=self.timestamp,
|
||||
repeat=repeat
|
||||
)
|
||||
|
||||
|
||||
class ReminderMonitor(TaskMonitor[int]):
|
||||
...
|
||||
|
||||
@@ -191,7 +69,7 @@ class Reminders(LionCog):
|
||||
|
||||
# Short term userid -> list[Reminder] cache, mainly for autocomplete
|
||||
self._user_reminder_cache: TTLCache[int, list[ReminderData.Reminder]] = TTLCache(1000, ttl=60)
|
||||
self._active_reminderlists: dict[int, ReminderListUI] = {}
|
||||
self._active_reminderlists: dict[int, ReminderList] = {}
|
||||
|
||||
async def cog_load(self):
|
||||
await self.data.init()
|
||||
@@ -212,6 +90,105 @@ class Reminders(LionCog):
|
||||
# Start firing reminders
|
||||
self.monitor.start()
|
||||
|
||||
# ----- Cog API -----
|
||||
|
||||
async def create_reminder(
|
||||
self,
|
||||
userid: int, remind_at: dt.datetime, content: str,
|
||||
message_link: Optional[str] = None,
|
||||
interval: Optional[int] = None,
|
||||
created_at: Optional[dt.datetime] = None,
|
||||
) -> ReminderData.Reminder:
|
||||
"""
|
||||
Create and schedule a new reminder from user-entered data.
|
||||
|
||||
Raises UserInputError if the requested parameters are invalid.
|
||||
"""
|
||||
now = utc_now()
|
||||
|
||||
if remind_at <= now:
|
||||
t = self.bot.translator.t
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'create_reminder|error:past',
|
||||
"The provided reminder time {timestamp} is in the past!"
|
||||
)).format(timestamp=discord.utils.format_dt(remind_at))
|
||||
)
|
||||
|
||||
if interval is not None and interval < 600:
|
||||
t = self.bot.translator.t
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'create_reminder|error:too_fast',
|
||||
"You cannot set a repeating reminder with a period less than 10 minutes."
|
||||
))
|
||||
)
|
||||
|
||||
existing = await self.data.Reminder.fetch_where(userid=userid)
|
||||
if len(existing) >= 25:
|
||||
t = self.bot.translator.t
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'create_reminder|error:too_many',
|
||||
"Sorry, you have reached the maximum of `25` reminders."
|
||||
))
|
||||
)
|
||||
|
||||
user = self.bot.get_user(userid)
|
||||
if not user:
|
||||
user = await self.bot.fetch_user(userid)
|
||||
if not user:
|
||||
raise ValueError(f"Target user {userid} does not exist.")
|
||||
|
||||
can_dm = await check_dm(user)
|
||||
if not can_dm:
|
||||
t = self.bot.translator.t
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'create_reminder|error:cannot_dm',
|
||||
"I cannot direct message you! Do you have me blocked or direct messages closed?"
|
||||
))
|
||||
)
|
||||
|
||||
created_at = created_at or now
|
||||
|
||||
# Passes validation, actually create
|
||||
reminder = await self.data.Reminder.create(
|
||||
userid=userid,
|
||||
remind_at=remind_at,
|
||||
content=content,
|
||||
message_link=message_link,
|
||||
interval=interval,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
# Schedule from executor
|
||||
await self.talk_schedule(reminder.reminderid).send(self.executor_name, wait_for_reply=False)
|
||||
|
||||
# Dispatch reminder update
|
||||
await self.dispatch_update_for(userid)
|
||||
|
||||
# Return fresh reminder
|
||||
return reminder
|
||||
|
||||
async def parse_time_static(self, timestr, timezone):
|
||||
timestr = timestr.strip()
|
||||
default = dt.datetime.now(tz=timezone).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if not timestr:
|
||||
return default
|
||||
try:
|
||||
ts = parse(timestr, fuzzy=True, default=default)
|
||||
except ParserError:
|
||||
t = self.bot.translator.t
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'parse_timestamp|error:parse',
|
||||
"Could not parse `{given}` as a valid reminder time. "
|
||||
"Try entering the time in the form `HH:MM` or `YYYY-MM-DD HH:MM`."
|
||||
)).format(given=timestr)
|
||||
)
|
||||
return ts
|
||||
|
||||
async def get_reminders_for(self, userid: int):
|
||||
"""
|
||||
Retrieve a list of reminders for the given userid, using the cache.
|
||||
@@ -348,116 +325,43 @@ class Reminders(LionCog):
|
||||
# Dispatch for analytics
|
||||
self.bot.dispatch('reminder_sent', reminder)
|
||||
|
||||
@cmds.hybrid_group(
|
||||
name=_p('cmd:reminders', "reminders")
|
||||
)
|
||||
async def reminders_group(self, ctx: LionContext):
|
||||
pass
|
||||
|
||||
@reminders_group.command(
|
||||
# No help string
|
||||
name=_p('cmd:reminders_show', "show"),
|
||||
@cmds.hybrid_command(
|
||||
name=_p('cmd:reminders', "reminders"),
|
||||
description=_p(
|
||||
'cmd:reminders_show|desc',
|
||||
"Display your current reminders."
|
||||
'cmd:reminders|desc',
|
||||
"View and set your reminders."
|
||||
)
|
||||
)
|
||||
async def cmd_reminders_show(self, ctx: LionContext):
|
||||
# No help string
|
||||
async def cmd_reminders(self, ctx: LionContext):
|
||||
"""
|
||||
Display the reminder widget for this user.
|
||||
"""
|
||||
t = self.bot.translator.t
|
||||
if not ctx.interaction:
|
||||
return
|
||||
|
||||
if ctx.author.id in self._active_reminderlists:
|
||||
await self._active_reminderlists[ctx.author.id].close(
|
||||
msg=t(_p(
|
||||
'cmd:reminders_show|close_elsewhere',
|
||||
"Closing since the list was opened elsewhere."
|
||||
))
|
||||
)
|
||||
ui = ReminderListUI(self.bot, ctx.author)
|
||||
await self._active_reminderlists[ctx.author.id].quit()
|
||||
ui = ReminderList(self.bot, ctx.author)
|
||||
try:
|
||||
self._active_reminderlists[ctx.author.id] = ui
|
||||
await ui.run(ctx.interaction)
|
||||
await ui.run(ctx.interaction, ephemeral=True)
|
||||
await ui.wait()
|
||||
finally:
|
||||
self._active_reminderlists.pop(ctx.author.id, None)
|
||||
|
||||
@reminders_group.command(
|
||||
name=_p('cmd:reminders_clear', "clear"),
|
||||
description=_p(
|
||||
'cmd:reminders_clear|desc',
|
||||
"Clear your reminder list."
|
||||
)
|
||||
@cmds.hybrid_group(
|
||||
name=_p('cmd:remindme', "remindme"),
|
||||
description=_p('cmd:remindme|desc', "View and set task reminders."),
|
||||
)
|
||||
async def cmd_reminders_clear(self, ctx: LionContext):
|
||||
# No help string
|
||||
"""
|
||||
Confirm and then clear all the reminders for this user.
|
||||
"""
|
||||
if not ctx.interaction:
|
||||
return
|
||||
async def remindme_group(self, ctx: LionContext):
|
||||
# Base command group for scheduling reminders.
|
||||
pass
|
||||
|
||||
t = self.bot.translator.t
|
||||
reminders = await self.data.Reminder.fetch_where(userid=ctx.author.id)
|
||||
if not reminders:
|
||||
await ctx.reply(
|
||||
embed=discord.Embed(
|
||||
description=t(_p(
|
||||
'cmd:reminders_clear|error:no_reminders',
|
||||
"You have no reminders to clear!"
|
||||
)),
|
||||
colour=discord.Colour.brand_red()
|
||||
),
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title=t(_p('cmd:reminders_clear|confirm|title', "Are You Sure?")),
|
||||
description=t(_np(
|
||||
'cmd:reminders_clear|confirm|desc',
|
||||
"Are you sure you want to delete your `{count}` reminder?",
|
||||
"Are you sure you want to clear your `{count}` reminders?",
|
||||
len(reminders)
|
||||
)).format(count=len(reminders))
|
||||
)
|
||||
|
||||
@AButton(label=t(_p('cmd:reminders_clear|confirm|button:yes', "Yes, clear my reminders")))
|
||||
async def confirm(interaction, press):
|
||||
await interaction.response.defer()
|
||||
reminders = await self.data.Reminder.table.delete_where(userid=ctx.author.id)
|
||||
await self.talk_cancel(*(r['reminderid'] for r in reminders)).send(self.executor_name, wait_for_reply=False)
|
||||
await ctx.interaction.edit_original_response(
|
||||
embed=discord.Embed(
|
||||
description=t(_p(
|
||||
'cmd:reminders_clear|success|desc',
|
||||
"Your reminders have been cleared!"
|
||||
)),
|
||||
colour=discord.Colour.brand_green()
|
||||
),
|
||||
view=None
|
||||
)
|
||||
await press.view.close()
|
||||
await self.dispatch_update_for(ctx.author.id)
|
||||
|
||||
@AButton(label=t(_p('cmd:reminders_clear|confirm|button:cancel', "Cancel")))
|
||||
async def deny(interaction, press):
|
||||
await interaction.response.defer()
|
||||
await ctx.interaction.delete_original_response()
|
||||
await press.view.close()
|
||||
|
||||
components = AsComponents(confirm, deny)
|
||||
await ctx.interaction.response.send_message(embed=embed, view=components, ephemeral=True)
|
||||
|
||||
@reminders_group.command(
|
||||
@remindme_group.command(
|
||||
name=_p('cmd:reminders_cancel', "cancel"),
|
||||
description=_p(
|
||||
'cmd:reminders_cancel|desc',
|
||||
"Cancel a single reminder. Use the menu in \"reminder show\" to cancel multiple reminders."
|
||||
"Cancel a single reminder. Use /reminders to clear or cancel multiple reminders."
|
||||
)
|
||||
)
|
||||
@appcmds.rename(
|
||||
@@ -576,13 +480,6 @@ class Reminders(LionCog):
|
||||
]
|
||||
return choices
|
||||
|
||||
@cmds.hybrid_group(
|
||||
name=_p('cmd:remindme', "remindme")
|
||||
)
|
||||
async def remindme_group(self, ctx: LionContext):
|
||||
# Base command group for scheduling reminders.
|
||||
pass
|
||||
|
||||
@remindme_group.command(
|
||||
name=_p('cmd:remindme_at', "at"),
|
||||
description=_p(
|
||||
@@ -596,118 +493,79 @@ class Reminders(LionCog):
|
||||
every=_p('cmd:remindme_at|param:every', "repeat_every"),
|
||||
)
|
||||
@appcmds.describe(
|
||||
time=_p('cmd:remindme_at|param:time|desc', "When you want to be reminded. (E.g. `4pm` or `16:00`)."),
|
||||
reminder=_p('cmd:remindme_at|param:reminder|desc', "What should the reminder be?"),
|
||||
every=_p('cmd:remindme_at|param:every|desc', "How often to repeat this reminder.")
|
||||
time=_p(
|
||||
'cmd:remindme_at|param:time|desc',
|
||||
"When you want to be reminded. (E.g. `4pm` or `16:00`)."
|
||||
),
|
||||
reminder=_p(
|
||||
'cmd:remindme_at|param:reminder|desc',
|
||||
"What should the reminder be?"
|
||||
),
|
||||
every=_p(
|
||||
'cmd:remindme_at|param:every|desc',
|
||||
"How often to repeat this reminder."
|
||||
)
|
||||
)
|
||||
async def cmd_remindme_at(
|
||||
self,
|
||||
ctx: LionContext,
|
||||
time: str,
|
||||
reminder: str,
|
||||
time: appcmds.Range[str, 1, 100],
|
||||
reminder: appcmds.Range[str, 1, 2000],
|
||||
every: Optional[Transform[int, DurationTransformer(60)]] = None
|
||||
):
|
||||
t = self.bot.translator.t
|
||||
reminders = await self.data.Reminder.fetch_where(userid=ctx.author.id)
|
||||
|
||||
# Guard against too many reminders
|
||||
if len(reminders) > 25:
|
||||
await ctx.error_reply(
|
||||
embed=error_embed(
|
||||
t(_p(
|
||||
'cmd_remindme_at|error:too_many|desc',
|
||||
"Sorry, you have reached the maximum of `25` reminders!"
|
||||
)),
|
||||
title=t(_p(
|
||||
'cmd_remindme_at|error:too_many|title',
|
||||
"Could not create reminder!"
|
||||
))
|
||||
),
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Guard against too frequent reminders
|
||||
if every is not None and every < 600:
|
||||
await ctx.reply(
|
||||
embed=error_embed(
|
||||
t(_p(
|
||||
'cmd_remindme_at|error:too_fast|desc',
|
||||
"You cannot set a repeating reminder with a period less than 10 minutes."
|
||||
)),
|
||||
title=t(_p(
|
||||
'cmd_remindme_at|error:too_fast|title',
|
||||
"Could not create reminder!"
|
||||
))
|
||||
),
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Parse the provided static time
|
||||
timezone = ctx.lmember.timezone
|
||||
time = time.strip()
|
||||
default = dt.datetime.now(tz=timezone).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
try:
|
||||
ts = parse(time, fuzzy=True, default=default)
|
||||
except ParserError:
|
||||
await ctx.reply(
|
||||
embed=error_embed(
|
||||
t(_p(
|
||||
'cmd:remindme_at|error:parse_time|desc',
|
||||
"Could not parse provided time `{given}`. Try entering e.g. `4 pm` or `16:00`."
|
||||
)).format(given=time),
|
||||
title=t(_p(
|
||||
'cmd:remindme_at|error:parse_time|title',
|
||||
"Could not create reminder!"
|
||||
))
|
||||
),
|
||||
ephemeral=True
|
||||
timezone = ctx.lmember.timezone
|
||||
remind_at = await self.parse_time_static(time, timezone)
|
||||
reminder = await self.create_reminder(
|
||||
userid=ctx.author.id,
|
||||
remind_at=remind_at,
|
||||
content=reminder,
|
||||
message_link=ctx.message.jump_url,
|
||||
interval=every,
|
||||
)
|
||||
return
|
||||
if ts < utc_now():
|
||||
await ctx.reply(
|
||||
embed=error_embed(
|
||||
t(_p(
|
||||
'cmd:remindme_at|error:past_time|desc',
|
||||
"Provided time is in the past!"
|
||||
)),
|
||||
title=t(_p(
|
||||
'cmd:remindme_at|error:past_time|title',
|
||||
"Could not create reminder!"
|
||||
))
|
||||
),
|
||||
ephemeral=True
|
||||
embed = reminder.set_response
|
||||
except UserInputError as e:
|
||||
embed = discord.Embed(
|
||||
title=t(_p(
|
||||
'cmd:remindme_at|error|title',
|
||||
"Could not create reminder!"
|
||||
)),
|
||||
description=e.msg,
|
||||
colour=discord.Colour.brand_red()
|
||||
)
|
||||
return
|
||||
# Everything seems to be in order
|
||||
# Create the reminder
|
||||
now = utc_now()
|
||||
rem = await self.data.Reminder.create(
|
||||
userid=ctx.author.id,
|
||||
remind_at=ts,
|
||||
content=reminder,
|
||||
message_link=ctx.message.jump_url,
|
||||
interval=every,
|
||||
created_at=now
|
||||
)
|
||||
|
||||
# Reminder created, request scheduling from executor shard
|
||||
await self.talk_schedule(rem.reminderid).send(self.executor_name, wait_for_reply=False)
|
||||
|
||||
# TODO Add repeat to description
|
||||
embed = discord.Embed(
|
||||
title=t(_p(
|
||||
'cmd:remindme_in|success|title',
|
||||
"Reminder Set at {timestamp}"
|
||||
)).format(timestamp=f"<t:{rem.timestamp}>"),
|
||||
description=f"> {rem.content}"
|
||||
)
|
||||
await ctx.reply(
|
||||
embed=embed,
|
||||
ephemeral=True
|
||||
)
|
||||
await self.dispatch_update_for(ctx.author.id)
|
||||
|
||||
@cmd_remindme_at.autocomplete('time')
|
||||
async def cmd_remindme_at_acmpl_time(self, interaction: discord.Interaction, partial: str):
|
||||
if interaction.guild:
|
||||
lmember = await self.bot.core.lions.fetch_member(interaction.guild.id, interaction.user.id)
|
||||
timezone = lmember.timezone
|
||||
else:
|
||||
luser = await self.bot.core.lions.fetch_user(interaction.user.id)
|
||||
timezone = luser.timezone
|
||||
|
||||
t = self.bot.translator.t
|
||||
try:
|
||||
timestamp = await self.parse_time_static(partial, timezone)
|
||||
choice = appcmds.Choice(
|
||||
name=timestamp.strftime('%Y-%m-%d %H:%M'),
|
||||
value=partial
|
||||
)
|
||||
except UserInputError:
|
||||
choice = appcmds.Choice(
|
||||
name=t(_p(
|
||||
'cmd:remindme_at|acmpl:time|error:parse',
|
||||
"Cannot parse \"{partial}\" as a time. Try the format HH:MM or YYYY-MM-DD HH:MM"
|
||||
)).format(partial=partial),
|
||||
value=partial
|
||||
)
|
||||
return [choice]
|
||||
|
||||
@remindme_group.command(
|
||||
name=_p('cmd:remindme_in', "in"),
|
||||
@@ -722,228 +580,49 @@ class Reminders(LionCog):
|
||||
every=_p('cmd:remindme_in|param:every', "repeat_every"),
|
||||
)
|
||||
@appcmds.describe(
|
||||
time=_p('cmd:remindme_in|param:time|desc', "How far into the future to set the reminder (e.g. 1 day 10h 5m)."),
|
||||
reminder=_p('cmd:remindme_in|param:reminder|desc', "What should the reminder be?"),
|
||||
every=_p('cmd:remindme_in|param:every|desc', "How often to repeat this reminder. (e.g. 1 day, or 2h)")
|
||||
time=_p(
|
||||
'cmd:remindme_in|param:time|desc',
|
||||
"How far into the future to set the reminder (e.g. 1 day 10h 5m)."
|
||||
),
|
||||
reminder=_p(
|
||||
'cmd:remindme_in|param:reminder|desc',
|
||||
"What should the reminder be?"
|
||||
),
|
||||
every=_p(
|
||||
'cmd:remindme_in|param:every|desc',
|
||||
"How often to repeat this reminder. (e.g. 1 day, or 2h)"
|
||||
)
|
||||
)
|
||||
async def cmd_remindme_in(
|
||||
self,
|
||||
ctx: LionContext,
|
||||
time: Transform[int, DurationTransformer(60)],
|
||||
reminder: appcmds.Range[str, 1, 1000], # TODO: Maximum length 1000?
|
||||
reminder: appcmds.Range[str, 1, 2000],
|
||||
every: Optional[Transform[int, DurationTransformer(60)]] = None
|
||||
):
|
||||
t = self.bot.translator.t
|
||||
reminders = await self.data.Reminder.fetch_where(userid=ctx.author.id)
|
||||
|
||||
# Guard against too many reminders
|
||||
if len(reminders) > 25:
|
||||
await ctx.error_reply(
|
||||
embed=error_embed(
|
||||
t(_p(
|
||||
'cmd_remindme_in|error:too_many|desc',
|
||||
"Sorry, you have reached the maximum of `25` reminders!"
|
||||
)),
|
||||
title=t(_p(
|
||||
'cmd_remindme_in|error:too_many|title',
|
||||
"Could not create reminder!"
|
||||
))
|
||||
),
|
||||
ephemeral=True
|
||||
try:
|
||||
remind_at = utc_now() + dt.timedelta(seconds=time)
|
||||
reminder = await self.create_reminder(
|
||||
userid=ctx.author.id,
|
||||
remind_at=remind_at,
|
||||
content=reminder,
|
||||
message_link=ctx.message.jump_url,
|
||||
interval=every,
|
||||
)
|
||||
return
|
||||
|
||||
# Guard against too frequent reminders
|
||||
if every is not None and every < 600:
|
||||
await ctx.reply(
|
||||
embed=error_embed(
|
||||
t(_p(
|
||||
'cmd_remindme_in|error:too_fast|desc',
|
||||
"You cannot set a repeating reminder with a period less than 10 minutes."
|
||||
)),
|
||||
title=t(_p(
|
||||
'cmd_remindme_in|error:too_fast|title',
|
||||
"Could not create reminder!"
|
||||
))
|
||||
),
|
||||
ephemeral=True
|
||||
embed = reminder.set_response
|
||||
except UserInputError as e:
|
||||
embed = discord.Embed(
|
||||
title=t(_p(
|
||||
'cmd:remindme_in|error|title',
|
||||
"Could not create reminder!"
|
||||
)),
|
||||
description=e.msg,
|
||||
colour=discord.Colour.brand_red()
|
||||
)
|
||||
return
|
||||
|
||||
# Everything seems to be in order
|
||||
# Create the reminder
|
||||
now = utc_now()
|
||||
rem = await self.data.Reminder.create(
|
||||
userid=ctx.author.id,
|
||||
remind_at=now + dt.timedelta(seconds=time),
|
||||
content=reminder,
|
||||
message_link=ctx.message.jump_url,
|
||||
interval=every,
|
||||
created_at=now
|
||||
)
|
||||
|
||||
# Reminder created, request scheduling from executor shard
|
||||
await self.talk_schedule(rem.reminderid).send(self.executor_name, wait_for_reply=False)
|
||||
|
||||
# TODO Add repeat to description
|
||||
embed = discord.Embed(
|
||||
title=t(_p(
|
||||
'cmd:remindme_in|success|title',
|
||||
"Reminder Set {timestamp}"
|
||||
)).format(timestamp=f"<t:{rem.timestamp}:R>"),
|
||||
description=f"> {rem.content}"
|
||||
)
|
||||
await ctx.reply(
|
||||
embed=embed,
|
||||
ephemeral=True
|
||||
)
|
||||
await self.dispatch_update_for(ctx.author.id)
|
||||
|
||||
|
||||
class ReminderListUI(LeoUI):
|
||||
def __init__(self, bot: LionBot, user: discord.User, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.bot = bot
|
||||
self.user = user
|
||||
|
||||
cog = bot.get_cog('Reminders')
|
||||
if cog is None:
|
||||
raise ValueError("Cannot create a ReminderUI without the Reminder cog!")
|
||||
self.cog: Reminders = cog
|
||||
self.userid = user.id
|
||||
|
||||
# Original interaction which sent the UI message
|
||||
# Since this is an ephemeral UI, we need this to update and delete
|
||||
self._interaction: Optional[discord.Interaction] = None
|
||||
self._reminders = []
|
||||
|
||||
async def cleanup(self):
|
||||
# Cleanup after an ephemeral UI
|
||||
# Just close if possible
|
||||
if self._interaction and not self._interaction.is_expired():
|
||||
try:
|
||||
await self._interaction.delete_original_response()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
@select()
|
||||
async def select_remove(self, interaction: discord.Interaction, selection):
|
||||
"""
|
||||
Select a number of reminders to delete.
|
||||
"""
|
||||
await interaction.response.defer()
|
||||
# Hopefully this is a list of reminderids
|
||||
values = selection.values
|
||||
# Delete from data
|
||||
await self.cog.data.Reminder.table.delete_where(reminderid=values)
|
||||
# Send cancellation
|
||||
await self.cog.talk_cancel(*values).send(self.cog.executor_name, wait_for_reply=False)
|
||||
self.cog._user_reminder_cache.pop(self.userid, None)
|
||||
await self.refresh()
|
||||
|
||||
async def refresh_select_remove(self):
|
||||
"""
|
||||
Refresh the select remove component from current state.
|
||||
"""
|
||||
t = self.bot.translator.t
|
||||
|
||||
self.select_remove.placeholder = t(_p(
|
||||
'ui:reminderlist|select:remove|placeholder',
|
||||
"Select to cancel."
|
||||
))
|
||||
self.select_remove.options = [
|
||||
SelectOption(
|
||||
label=f"[{i}] {reminder.content[:50] + '...' * (len(reminder.content) > 50)}",
|
||||
value=reminder.reminderid,
|
||||
emoji=self.bot.config.emojis.getemoji('clock')
|
||||
)
|
||||
for i, reminder in enumerate(self._reminders, start=1)
|
||||
]
|
||||
self.select_remove.min_values = 1
|
||||
self.select_remove.max_values = len(self._reminders)
|
||||
|
||||
async def refresh_reminders(self):
|
||||
self._reminders = await self.cog.get_reminders_for(self.userid)
|
||||
|
||||
async def refresh(self):
|
||||
"""
|
||||
Refresh the UI message and components.
|
||||
"""
|
||||
if not self._interaction:
|
||||
raise ValueError("Cannot refresh ephemeral UI without an origin interaction!")
|
||||
|
||||
await self.refresh_reminders()
|
||||
await self.refresh_select_remove()
|
||||
embed = await self.build_embed()
|
||||
|
||||
if self._reminders:
|
||||
self.set_layout((self.select_remove,))
|
||||
else:
|
||||
self.set_layout()
|
||||
|
||||
try:
|
||||
if not self._interaction.response.is_done():
|
||||
# Fresh message
|
||||
await self._interaction.response.send_message(embed=embed, view=self, ephemeral=True)
|
||||
else:
|
||||
# Update existing message
|
||||
await self._interaction.edit_original_response(embed=embed, view=self)
|
||||
except discord.HTTPException:
|
||||
await self.close()
|
||||
|
||||
async def run(self, interaction: discord.Interaction):
|
||||
"""
|
||||
Run the UI responding to the given interaction.
|
||||
"""
|
||||
self._interaction = interaction
|
||||
await self.refresh()
|
||||
|
||||
async def build_embed(self):
|
||||
"""
|
||||
Build the reminder list embed.
|
||||
"""
|
||||
t = self.bot.translator.t
|
||||
reminders = self._reminders
|
||||
|
||||
if reminders:
|
||||
lines = []
|
||||
num_len = len(str(len(reminders)))
|
||||
for i, reminder in enumerate(reminders):
|
||||
lines.append(
|
||||
"`[{:<{}}]` | {}".format(
|
||||
i+1,
|
||||
num_len,
|
||||
reminder.formatted
|
||||
)
|
||||
)
|
||||
description = '\n'.join(lines)
|
||||
|
||||
embed = discord.Embed(
|
||||
description=description,
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=utc_now()
|
||||
).set_author(
|
||||
name=t(_p(
|
||||
'ui:reminderlist|embed:list|author',
|
||||
"{name}'s reminders"
|
||||
)).format(name=self.user.display_name),
|
||||
icon_url=self.user.avatar
|
||||
).set_footer(
|
||||
text=t(_p(
|
||||
'ui:reminderlist|embed:list|footer',
|
||||
"Click a reminder twice to jump to the context!"
|
||||
))
|
||||
)
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
description=t(_p(
|
||||
'ui:reminderlist|embed:no_reminders|desc',
|
||||
"You have no reminders to display!\n"
|
||||
"Use {remindme} to create a new reminder."
|
||||
)).format(
|
||||
remindme=self.bot.core.cmd_name_cache['remindme'].mention,
|
||||
)
|
||||
)
|
||||
|
||||
return embed
|
||||
|
||||
165
src/modules/reminders/data.py
Normal file
165
src/modules/reminders/data.py
Normal file
@@ -0,0 +1,165 @@
|
||||
import discord
|
||||
|
||||
from data import RowModel, Registry
|
||||
from data.columns import Integer, String, Timestamp, Bool
|
||||
|
||||
from babel import ctx_translator
|
||||
from utils.lib import strfdur
|
||||
from . import babel
|
||||
|
||||
|
||||
_, _p, _np = babel._, babel._p, babel._np
|
||||
|
||||
|
||||
class ReminderData(Registry, name='reminders'):
|
||||
class Reminder(RowModel):
|
||||
"""
|
||||
Model representing a single reminder.
|
||||
Since reminders are likely to change across shards,
|
||||
does not use an explicit reference cache.
|
||||
|
||||
Schema
|
||||
------
|
||||
CREATE TABLE reminders(
|
||||
reminderid SERIAL PRIMARY KEY,
|
||||
userid BIGINT NOT NULL REFERENCES user_config(userid) ON DELETE CASCADE,
|
||||
remind_at TIMESTAMPTZ NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
message_link TEXT,
|
||||
interval INTEGER,
|
||||
created_at TIMESTAMP DEFAULT (now() at time zone 'utc'),
|
||||
title TEXT,
|
||||
footer TEXT
|
||||
);
|
||||
CREATE INDEX reminder_users ON reminders (userid);
|
||||
"""
|
||||
_tablename_ = 'reminders'
|
||||
|
||||
reminderid = Integer(primary=True)
|
||||
|
||||
userid = Integer() # User which created the reminder
|
||||
remind_at = Timestamp() # Time when the reminder should be executed
|
||||
content = String() # Content the user gave us to remind them
|
||||
message_link = String() # Link to original confirmation message, for context
|
||||
interval = Integer() # Repeat interval, if applicable
|
||||
created_at = Timestamp() # Time when this reminder was originally created
|
||||
title = String() # Title of the final reminder embed, only set in automated reminders
|
||||
footer = String() # Footer of the final reminder embed, only set in automated reminders
|
||||
failed = Bool() # Whether the reminder was already attempted and failed
|
||||
|
||||
@property
|
||||
def timestamp(self) -> int:
|
||||
"""
|
||||
Time when this reminder should be executed (next) as an integer timestamp.
|
||||
"""
|
||||
return int(self.remind_at.timestamp())
|
||||
|
||||
@property
|
||||
def set_response(self) -> discord.Embed:
|
||||
t = ctx_translator.get().t
|
||||
embed = discord.Embed(
|
||||
title=t(_p(
|
||||
'reminder_set|title',
|
||||
"Reminder Set!"
|
||||
)),
|
||||
description=t(_p(
|
||||
'reminder_set|desc',
|
||||
"At {timestamp} I will remind you about:\n"
|
||||
"> {content}"
|
||||
)).format(
|
||||
timestamp=discord.utils.format_dt(self.remind_at),
|
||||
content=self.content,
|
||||
)[:2048],
|
||||
colour=discord.Colour.brand_green(),
|
||||
)
|
||||
if self.interval:
|
||||
embed.add_field(
|
||||
name=t(_p(
|
||||
'reminder_set|field:repeat|name',
|
||||
"Repeats"
|
||||
)),
|
||||
value=t(_p(
|
||||
'reminder_set|field:repeat|value',
|
||||
"This reminder will repeat every `{interval}` (after the first reminder)."
|
||||
)).format(interval=strfdur(self.interval, short=False)),
|
||||
inline=False
|
||||
)
|
||||
return embed
|
||||
|
||||
@property
|
||||
def embed(self) -> discord.Embed:
|
||||
t = ctx_translator.get().t
|
||||
|
||||
embed = discord.Embed(
|
||||
title=self.title or t(_p('reminder|embed', "You asked me to remind you!")),
|
||||
colour=discord.Colour.orange(),
|
||||
description=self.content,
|
||||
timestamp=self.remind_at
|
||||
)
|
||||
|
||||
if self.message_link:
|
||||
embed.add_field(
|
||||
name=t(_p('reminder|embed', "Context?")),
|
||||
value="[{click}]({link})".format(
|
||||
click=t(_p('reminder|embed', "Click Here")),
|
||||
link=self.message_link
|
||||
)
|
||||
)
|
||||
|
||||
if self.interval:
|
||||
embed.add_field(
|
||||
name=t(_p('reminder|embed', "Next reminder")),
|
||||
value=f"<t:{self.timestamp + self.interval}:R>"
|
||||
)
|
||||
|
||||
if self.footer:
|
||||
embed.set_footer(text=self.footer)
|
||||
|
||||
return embed
|
||||
|
||||
@property
|
||||
def formatted(self):
|
||||
"""
|
||||
Single-line string format for the reminder, intended for an embed.
|
||||
"""
|
||||
t = ctx_translator.get().t
|
||||
content = self.content
|
||||
trunc_content = content[:50] + '...' * (len(content) > 50)
|
||||
|
||||
if interval := self.interval:
|
||||
if not interval % (24 * 60 * 60):
|
||||
# Exact day case
|
||||
days = interval // (24 * 60 * 60)
|
||||
repeat = t(_np(
|
||||
'reminder|formatted|interval',
|
||||
"Every day",
|
||||
"Every `{days}` days",
|
||||
days
|
||||
)).format(days=days)
|
||||
elif not interval % (60 * 60):
|
||||
# Exact hour case
|
||||
hours = interval // (60 * 60)
|
||||
repeat = t(_np(
|
||||
'reminder|formatted|interval',
|
||||
"Every hour",
|
||||
"Every `{hours}` hours",
|
||||
hours
|
||||
)).format(hours=hours)
|
||||
else:
|
||||
# Inexact interval, e.g 10m or 1h 10m.
|
||||
# Use short duration format
|
||||
repeat = t(_p(
|
||||
'reminder|formatted|interval',
|
||||
"Every `{duration}`"
|
||||
)).format(duration=strfdur(interval))
|
||||
|
||||
repeat = f"({repeat})"
|
||||
else:
|
||||
repeat = ""
|
||||
|
||||
return "<t:{timestamp}:R>, [{content}]({jump_link}) {repeat}".format(
|
||||
jump_link=self.message_link,
|
||||
content=trunc_content,
|
||||
timestamp=self.timestamp,
|
||||
repeat=repeat
|
||||
)
|
||||
304
src/modules/reminders/ui.py
Normal file
304
src/modules/reminders/ui.py
Normal file
@@ -0,0 +1,304 @@
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
|
||||
import discord
|
||||
from discord.ui.select import select, Select, SelectOption
|
||||
from discord.ui.button import button, Button, ButtonStyle
|
||||
from discord.ui.text_input import TextInput, TextStyle
|
||||
|
||||
from meta import LionBot
|
||||
from meta.errors import UserInputError
|
||||
from utils.lib import utc_now, MessageArgs, parse_duration
|
||||
from utils.ui import MessageUI, AButton, AsComponents, ConfigEditor
|
||||
|
||||
from . import babel, logger
|
||||
|
||||
_, _p, _np = babel._, babel._p, babel._np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .cog import Reminders
|
||||
|
||||
|
||||
class ReminderList(MessageUI):
|
||||
def __init__(self, bot: LionBot, user: discord.User, **kwargs):
|
||||
super().__init__(callerid=user.id, **kwargs)
|
||||
|
||||
self.bot = bot
|
||||
self.user = user
|
||||
self.userid = user.id
|
||||
|
||||
self.cog: 'Reminders' = bot.get_cog('Reminders')
|
||||
if self.cog is None:
|
||||
raise ValueError("Cannot initialise ReminderList without loaded Reminder cog.")
|
||||
|
||||
# UI state
|
||||
self._reminders = []
|
||||
|
||||
# ----- UI API -----
|
||||
# ----- UI Components -----
|
||||
# Clear button
|
||||
@button(label="CLEAR_BUTTON_PLACEHOLDER", style=ButtonStyle.red)
|
||||
async def clear_button(self, press: discord.Interaction, pressed: Button):
|
||||
t = self.bot.translator.t
|
||||
|
||||
reminders = self._reminders
|
||||
embed = discord.Embed(
|
||||
title=t(_p('ui:reminderlist|button:clear|confirm|title', "Are You Sure?")),
|
||||
description=t(_np(
|
||||
'ui:reminderlist|button:clear|confirm|desc',
|
||||
"Are you sure you want to delete your `{count}` reminder?",
|
||||
"Are you sure you want to clear your `{count}` reminders?",
|
||||
len(reminders)
|
||||
)).format(count=len(reminders)),
|
||||
colour=discord.Colour.dark_orange()
|
||||
)
|
||||
|
||||
@AButton(label=t(_p('ui:reminderlist|button:clear|confirm|button:yes', "Yes, clear my reminders")))
|
||||
async def confirm(interaction, pressed):
|
||||
await interaction.response.defer()
|
||||
reminders = await self.cog.data.Reminder.table.delete_where(userid=self.userid)
|
||||
await self.cog.talk_cancel(*(r['reminderid'] for r in reminders)).send(
|
||||
self.cog.executor_name, wait_for_reply=False
|
||||
)
|
||||
await press.edit_original_response(
|
||||
embed=discord.Embed(
|
||||
description=t(_p(
|
||||
'ui:reminderlist|button:clear|success|desc',
|
||||
"Your reminders have been cleared!"
|
||||
)),
|
||||
colour=discord.Colour.brand_green()
|
||||
),
|
||||
view=None
|
||||
)
|
||||
await pressed.view.close()
|
||||
await self.cog.dispatch_update_for(self.userid)
|
||||
|
||||
@AButton(label=t(_p('ui:reminderlist|button:clear|confirm|button:cancel', "Cancel")))
|
||||
async def deny(interaction, pressed):
|
||||
await interaction.response.defer()
|
||||
await press.delete_original_response()
|
||||
await pressed.view.close()
|
||||
|
||||
components = AsComponents(confirm, deny)
|
||||
await press.response.send_message(embed=embed, view=components, ephemeral=True)
|
||||
|
||||
async def clear_button_refresh(self):
|
||||
self.clear_button.label = self.bot.translator.t(_p(
|
||||
'ui:reminderlist|button:clear|label',
|
||||
"Clear Reminders"
|
||||
))
|
||||
|
||||
# New reminder button
|
||||
@button(label="NEW_BUTTON_PLACEHOLDER", style=ButtonStyle.green)
|
||||
async def new_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Pop up a modal for the user to enter new reminder information.
|
||||
"""
|
||||
t = self.bot.translator.t
|
||||
if press.guild:
|
||||
lmember = await self.bot.core.lions.fetch_member(press.guild.id, press.user.id)
|
||||
timezone = lmember.timezone
|
||||
else:
|
||||
luser = await self.bot.core.lions.fetch_user(press.user.id)
|
||||
timezone = luser.timezone
|
||||
default = dt.datetime.now(tz=timezone).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
time_field = TextInput(
|
||||
label=t(_p(
|
||||
'ui:reminderlist|button:new|modal|field:time|label',
|
||||
"When would you like to be reminded?"
|
||||
)),
|
||||
placeholder=default.strftime('%Y-%m-%d %H:%M'),
|
||||
required=True,
|
||||
max_length=100,
|
||||
)
|
||||
|
||||
interval_field = TextInput(
|
||||
label=t(_p(
|
||||
'ui:reminderlist|button:new|modal|field:repeat|label',
|
||||
"How often should the reminder repeat?"
|
||||
)),
|
||||
placeholder=t(_p(
|
||||
'ui:reminderlist|button:new|modal|field:repeat|placeholder',
|
||||
"1 day 10 hours 5 minutes (Leave empty for no repeat.)"
|
||||
)),
|
||||
required=False,
|
||||
max_length=100,
|
||||
)
|
||||
|
||||
content_field = TextInput(
|
||||
label=t(_p(
|
||||
'ui:reminderlist|button:new|modal|field:content|label',
|
||||
"What should I remind you?"
|
||||
)),
|
||||
required=True,
|
||||
style=TextStyle.long,
|
||||
max_length=2000,
|
||||
)
|
||||
|
||||
modal = ConfigEditor(
|
||||
time_field, interval_field, content_field,
|
||||
title=t(_p(
|
||||
'ui:reminderlist|button:new|modal|title',
|
||||
"Set a Reminder"
|
||||
))
|
||||
)
|
||||
|
||||
@modal.submit_callback()
|
||||
async def create_reminder(interaction: discord.Interaction):
|
||||
remind_at = await self.cog.parse_time_static(time_field.value, timezone)
|
||||
if intervalstr := interval_field.value:
|
||||
interval = parse_duration(intervalstr)
|
||||
if interval is None:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'ui:reminderlist|button:new|modal|parse|error:interval',
|
||||
"Cannot parse '{value}' as a duration."
|
||||
)).format(value=intervalstr)
|
||||
)
|
||||
else:
|
||||
interval = None
|
||||
|
||||
message = await self._original.original_response()
|
||||
|
||||
reminder = await self.cog.create_reminder(
|
||||
userid=self.userid,
|
||||
remind_at=remind_at,
|
||||
content=content_field.value,
|
||||
message_link=message.jump_url,
|
||||
interval=interval,
|
||||
)
|
||||
embed = reminder.set_response
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
await press.response.send_modal(modal)
|
||||
|
||||
async def new_button_refresh(self):
|
||||
self.new_button.label = self.bot.translator.t(_p(
|
||||
'ui:reminderlist|button:new|label',
|
||||
"New Reminder"
|
||||
))
|
||||
self.new_button.disabled = (len(self._reminders) >= 25)
|
||||
|
||||
# Cancel menu
|
||||
@select(cls=Select, placeholder="CANCEL_REMINDER_PLACEHOLDER", min_values=0, max_values=1)
|
||||
async def cancel_menu(self, selection: discord.Interaction, selected):
|
||||
"""
|
||||
Select a number of reminders to delete.
|
||||
"""
|
||||
await selection.response.defer()
|
||||
if selected.values:
|
||||
# Hopefully this is a list of reminderids
|
||||
values = selected.values
|
||||
|
||||
# Delete from data
|
||||
await self.cog.data.Reminder.table.delete_where(reminderid=values)
|
||||
|
||||
# Send cancellation
|
||||
await self.cog.talk_cancel(*values).send(self.cog.executor_name, wait_for_reply=False)
|
||||
|
||||
self.cog._user_reminder_cache.pop(self.userid, None)
|
||||
await self.refresh()
|
||||
|
||||
async def cancel_menu_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
self.cancel_menu.placeholder = t(_p(
|
||||
'ui:reminderlist|select:remove|placeholder',
|
||||
"Select to cancel"
|
||||
))
|
||||
self.cancel_menu.options = [
|
||||
SelectOption(
|
||||
label=f"[{i}] {reminder.content[:50] + '...' * (len(reminder.content) > 50)}",
|
||||
value=reminder.reminderid,
|
||||
emoji=self.bot.config.emojis.getemoji('clock')
|
||||
)
|
||||
for i, reminder in enumerate(self._reminders, start=1)
|
||||
]
|
||||
self.cancel_menu.min_values = 0
|
||||
self.cancel_menu.max_values = len(self._reminders)
|
||||
|
||||
# ----- UI Flow -----
|
||||
async def refresh_layout(self):
|
||||
to_refresh = (
|
||||
self.cancel_menu_refresh(),
|
||||
self.new_button_refresh(),
|
||||
self.clear_button_refresh(),
|
||||
)
|
||||
await asyncio.gather(*to_refresh)
|
||||
|
||||
if self._reminders:
|
||||
self.set_layout(
|
||||
(self.new_button, self.clear_button,),
|
||||
(self.cancel_menu,),
|
||||
)
|
||||
else:
|
||||
self.set_layout(
|
||||
(self.new_button,),
|
||||
)
|
||||
|
||||
async def make_message(self) -> MessageArgs:
|
||||
t = self.bot.translator.t
|
||||
reminders = self._reminders
|
||||
|
||||
if reminders:
|
||||
lines = []
|
||||
num_len = len(str(len(reminders)))
|
||||
for i, reminder in enumerate(reminders):
|
||||
lines.append(
|
||||
"`[{:<{}}]` | {}".format(
|
||||
i+1,
|
||||
num_len,
|
||||
reminder.formatted
|
||||
)
|
||||
)
|
||||
description = '\n'.join(lines)
|
||||
|
||||
embed = discord.Embed(
|
||||
description=description,
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=utc_now()
|
||||
).set_author(
|
||||
name=t(_p(
|
||||
'ui:reminderlist|embed:list|author',
|
||||
"Your reminders"
|
||||
)),
|
||||
icon_url=self.user.avatar or self.user.default_avatar
|
||||
).set_footer(
|
||||
text=t(_p(
|
||||
'ui:reminderlist|embed:list|footer',
|
||||
"Click a reminder to jump back to the context!"
|
||||
))
|
||||
)
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
title=t(_p(
|
||||
'ui:reminderlist|embed:no_reminders|title',
|
||||
"You have no reminders set!"
|
||||
)).format(
|
||||
remindme=self.bot.core.cmd_name_cache['remindme'].mention,
|
||||
),
|
||||
colour=discord.Colour.dark_orange(),
|
||||
)
|
||||
embed.add_field(
|
||||
name=t(_p(
|
||||
'ui:reminderlist|embed|tips:name',
|
||||
"Reminder Tips"
|
||||
)),
|
||||
value=t(_p(
|
||||
'ui:reminderlist|embed|tips:value',
|
||||
"- Use {at_cmd} to set a reminder at a known time (e.g. `at 10 am`).\n"
|
||||
"- Use {in_cmd} to set a reminder in a certain time (e.g. `in 2 hours`).\n"
|
||||
"- Both commands support repeating reminders using the `every` parameter.\n"
|
||||
"- Remember to tell me your timezone with {timezone_cmd} if you haven't already!"
|
||||
)).format(
|
||||
at_cmd=self.bot.core.mention_cmd('remindme at'),
|
||||
in_cmd=self.bot.core.mention_cmd('remindme in'),
|
||||
timezone_cmd=self.bot.core.mention_cmd('my timezone'),
|
||||
)
|
||||
)
|
||||
|
||||
return MessageArgs(embed=embed)
|
||||
|
||||
async def reload(self):
|
||||
self._reminders = await self.cog.get_reminders_for(self.userid)
|
||||
@@ -287,7 +287,7 @@ class RoleMenuCog(LionCog):
|
||||
|
||||
error = None
|
||||
message = None
|
||||
splits = msgstr.strip().rsplit('/', maxsplit=2)
|
||||
splits = msgstr.strip().rsplit('/', maxsplit=2)[-2:]
|
||||
if len(splits) == 2 and splits[0].isdigit() and splits[1].isdigit():
|
||||
chid, mid = map(int, splits)
|
||||
channel = guild.get_channel(chid)
|
||||
@@ -678,7 +678,7 @@ class RoleMenuCog(LionCog):
|
||||
target_mine = True
|
||||
else:
|
||||
# Parse provided message link into a Message
|
||||
target_message: discord.Message = await self._parse_msg(message)
|
||||
target_message: discord.Message = await self._parse_msg(ctx.guild, message)
|
||||
target_mine = (target_message.author == ctx.guild.me)
|
||||
|
||||
# Check that this message is not already attached to a role menu
|
||||
@@ -747,7 +747,7 @@ class RoleMenuCog(LionCog):
|
||||
message_data['content'] = target_message.content
|
||||
if target_message.embeds:
|
||||
message_data['embed'] = target_message.embeds[0].to_dict()
|
||||
rawmessage = json.dumps(message_data)
|
||||
rawmessagedata = json.dumps(message_data)
|
||||
else:
|
||||
if rawmessage is not None:
|
||||
# Attempt to parse rawmessage
|
||||
@@ -971,14 +971,21 @@ class RoleMenuCog(LionCog):
|
||||
)
|
||||
# TODO: Generate the custom message from the template if it doesn't exist
|
||||
|
||||
# TODO: Pathway for setting menu style
|
||||
|
||||
if rawmessage is not None:
|
||||
msg_config = target.config.rawmessage
|
||||
content = await msg_config.download_attachment(rawmessage)
|
||||
data = await msg_config._parse_string(content)
|
||||
data = await msg_config._parse_string(0, content)
|
||||
update_args[msg_config._column] = data
|
||||
if template is None:
|
||||
update_args[self.data.RoleMenu.templateid.name] = None
|
||||
ack_lines.append(msg_config.update_message)
|
||||
ack_lines.append(
|
||||
t(_p(
|
||||
'cmd:rolemenu_edit|parse:custom_message|success',
|
||||
"Custom menu message updated."
|
||||
))
|
||||
)
|
||||
|
||||
# Update the data, if applicable
|
||||
if update_args:
|
||||
@@ -1185,7 +1192,7 @@ class RoleMenuCog(LionCog):
|
||||
label: Optional[appcmds.Range[str, 1, 100]] = None,
|
||||
emoji: Optional[appcmds.Range[str, 0, 100]] = None,
|
||||
description: Optional[appcmds.Range[str, 0, 100]] = None,
|
||||
price: Optional[appcmds.Range[int, 0, MAX_COINS]] = None,
|
||||
price: Optional[appcmds.Range[int, -MAX_COINS, MAX_COINS]] = None,
|
||||
duration: Optional[Transform[int, DurationTransformer(60)]] = None,
|
||||
):
|
||||
# Type checking guards
|
||||
@@ -1356,7 +1363,7 @@ class RoleMenuCog(LionCog):
|
||||
await target.update_message()
|
||||
if target_is_reaction:
|
||||
try:
|
||||
await self.menu.update_reactons()
|
||||
await target.update_reactons()
|
||||
except SafeCancellation as e:
|
||||
embed.add_field(
|
||||
name=t(_p(
|
||||
@@ -1441,7 +1448,7 @@ class RoleMenuCog(LionCog):
|
||||
label: Optional[appcmds.Range[str, 1, 100]] = None,
|
||||
emoji: Optional[appcmds.Range[str, 0, 100]] = None,
|
||||
description: Optional[appcmds.Range[str, 0, 100]] = None,
|
||||
price: Optional[appcmds.Range[int, 0, MAX_COINS]] = None,
|
||||
price: Optional[appcmds.Range[int, -MAX_COINS, MAX_COINS]] = None,
|
||||
duration: Optional[Transform[int, DurationTransformer(60)]] = None,
|
||||
):
|
||||
# Type checking wards
|
||||
|
||||
@@ -165,7 +165,7 @@ class RoleMenu:
|
||||
await menu.attach()
|
||||
return menu
|
||||
|
||||
async def fetch_message(self, refresh=False):
|
||||
async def fetch_message(self, refresh=False) -> Optional[discord.Message]:
|
||||
"""
|
||||
Fetch the message the menu is attached to.
|
||||
"""
|
||||
@@ -529,11 +529,17 @@ class RoleMenu:
|
||||
"Role removed"
|
||||
))
|
||||
)
|
||||
if total_refund:
|
||||
if total_refund > 0:
|
||||
embed.description = t(_p(
|
||||
'rolemenu|deselect|success:refund|desc',
|
||||
"You have removed **{role}**, and been refunded {coin} **{amount}**."
|
||||
)).format(role=role.name, coin=self.bot.config.emojis.coin, amount=total_refund)
|
||||
if total_refund < 0:
|
||||
# TODO: Consider disallowing them from removing roles if their balance would go negative
|
||||
embed.description = t(_p(
|
||||
'rolemenu|deselect|success:negrefund|desc',
|
||||
"You have removed **{role}**, and have lost {coin} **{amount}**."
|
||||
)).format(role=role.name, coin=self.bot.config.emojis.coin, amount=-total_refund)
|
||||
else:
|
||||
embed.description = t(_p(
|
||||
'rolemenu|deselect|success:norefund|desc',
|
||||
@@ -551,7 +557,7 @@ class RoleMenu:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'rolemenu|select|error:required_role',
|
||||
"You need to have the **{role}** role to use this!"
|
||||
"You need to have the role **{role}** required to use this menu!"
|
||||
)).format(role=name)
|
||||
)
|
||||
|
||||
@@ -647,7 +653,7 @@ class RoleMenu:
|
||||
"Role equipped"
|
||||
))
|
||||
)
|
||||
if price > 0:
|
||||
if price:
|
||||
embed.description = t(_p(
|
||||
'rolemenu|select|success:purchase|desc',
|
||||
"You have purchased the role **{role}** for {coin}**{amount}**"
|
||||
@@ -665,6 +671,7 @@ class RoleMenu:
|
||||
)).format(
|
||||
timestamp=discord.utils.format_dt(expiry)
|
||||
)
|
||||
# TODO Event logging
|
||||
return embed
|
||||
|
||||
async def interactive_selection(self, interaction: discord.Interaction, menuroleid: int):
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from typing import Optional
|
||||
import discord
|
||||
|
||||
from settings import ModelData
|
||||
from settings.groups import SettingGroup, ModelConfig, SettingDotDict
|
||||
from settings.setting_types import (
|
||||
RoleSetting, BoolSetting, StringSetting, DurationSetting, EmojiSetting
|
||||
RoleSetting, StringSetting, DurationSetting, EmojiSetting
|
||||
)
|
||||
from core.setting_types import CoinSetting
|
||||
from utils.ui import AButton, AsComponents
|
||||
from meta.errors import UserInputError
|
||||
from meta import conf
|
||||
from babel.translator import ctx_translator
|
||||
from constants import MAX_COINS
|
||||
|
||||
from .data import RoleMenuData
|
||||
from . import babel
|
||||
@@ -74,6 +77,9 @@ class RoleMenuRoleOptions(SettingGroup):
|
||||
"This menu item will now give the role {role}."
|
||||
)).format(role=self.formatted)
|
||||
return resp
|
||||
else:
|
||||
raise ValueError("Attempt to call update_message without a value.")
|
||||
|
||||
|
||||
@RoleMenuRoleConfig.register_model_setting
|
||||
class Label(ModelData, StringSetting):
|
||||
@@ -138,7 +144,9 @@ class RoleMenuRoleOptions(SettingGroup):
|
||||
return button
|
||||
|
||||
@classmethod
|
||||
async def _parse_string(cls, parent_id, string: str, interaction: discord.Interaction = None, **kwargs):
|
||||
async def _parse_string(cls, parent_id, string: str,
|
||||
interaction: Optional[discord.Interaction] = None,
|
||||
**kwargs):
|
||||
emojistr = await super()._parse_string(parent_id, string, interaction=interaction, **kwargs)
|
||||
if emojistr and interaction is not None:
|
||||
# Use the interaction to test
|
||||
@@ -151,7 +159,7 @@ class RoleMenuRoleOptions(SettingGroup):
|
||||
view=view,
|
||||
)
|
||||
except discord.HTTPException:
|
||||
t = interaction.client.translator.t
|
||||
t = ctx_translator.get().t
|
||||
raise UserInputError(t(_p(
|
||||
'roleset:emoji|error:test_emoji',
|
||||
"The selected emoji `{emoji}` is invalid or has been deleted."
|
||||
@@ -218,34 +226,43 @@ class RoleMenuRoleOptions(SettingGroup):
|
||||
_display_name = _p('roleset:price', "price")
|
||||
_desc = _p(
|
||||
'roleset:price|desc',
|
||||
"Price of the role, in LionCoins."
|
||||
"Price of the role, in LionCoins. May be negative."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'roleset:price|long_desc',
|
||||
"How much the role costs when selected, in LionCoins."
|
||||
"How many LionCoins should be deducted from a member's account "
|
||||
"when they equip this role through this menu.\n"
|
||||
"The price may be negative, in which case the member will instead be rewarded "
|
||||
"coins when they equip the role."
|
||||
)
|
||||
_accepts = _p(
|
||||
'roleset:price|accepts',
|
||||
"Amount of coins that the role costs."
|
||||
"Amount of coins that the role costs when equipped."
|
||||
)
|
||||
_default = 0
|
||||
_min = - MAX_COINS
|
||||
_model = RoleMenuData.RoleMenuRole
|
||||
_column = RoleMenuData.RoleMenuRole.price.name
|
||||
|
||||
@property
|
||||
def update_message(self) -> str:
|
||||
t = ctx_translator.get().t
|
||||
value = self.value
|
||||
if value:
|
||||
value = self.value or 0
|
||||
if value > 0:
|
||||
resp = t(_p(
|
||||
'roleset:price|set_response:set',
|
||||
"This role will now cost {price} to equip."
|
||||
)).format(price=self.formatted)
|
||||
'roleset:price|set_response:positive',
|
||||
"Equipping this role will now cost {coin}**{price}**."
|
||||
)).format(price=value, coin=conf.emojis.coin)
|
||||
elif value == 0:
|
||||
resp = t(_p(
|
||||
'roleset:price|set_response:zero',
|
||||
"Equipping this role is now free."
|
||||
))
|
||||
else:
|
||||
resp = t(_p(
|
||||
'roleset:price|set_response:unset',
|
||||
"This role will now be free to equip from this role menu."
|
||||
))
|
||||
'roleset:price|set_response:negative',
|
||||
"Equipping this role will now reward {coin}**{price}**."
|
||||
)).format(price=-value, coin=conf.emojis.coin)
|
||||
return resp
|
||||
|
||||
@RoleMenuRoleConfig.register_model_setting
|
||||
|
||||
@@ -174,7 +174,7 @@ async def threecolumn_template(menu) -> MessageArgs:
|
||||
)
|
||||
async def shop_template(menu) -> MessageArgs:
|
||||
menuroles = menu.roles
|
||||
width = max(len(str(menurole.config.price.data)) for menurole in menuroles)
|
||||
width = max(len(str(menurole.config.price.data)) for menurole in menuroles) if menuroles else 0
|
||||
|
||||
lines = []
|
||||
for menurole in menuroles:
|
||||
|
||||
@@ -190,7 +190,10 @@ class MenuEditor(MessageUI):
|
||||
if not userstr:
|
||||
new_data = None
|
||||
else:
|
||||
new_data = await instance._parse_string(instance.parent_id, userstr)
|
||||
new_data = await instance._parse_string(
|
||||
instance.parent_id, userstr,
|
||||
guildid=self.menu.data.guildid
|
||||
)
|
||||
instance.data = new_data
|
||||
modified.append(instance)
|
||||
if modified:
|
||||
@@ -349,7 +352,9 @@ class MenuEditor(MessageUI):
|
||||
if not userstr:
|
||||
new_data = None
|
||||
else:
|
||||
new_data = await instance._parse_string(instance.parent_id, userstr, interaction=interaction)
|
||||
new_data = await instance._parse_string(
|
||||
instance.parent_id, userstr, interaction=interaction
|
||||
)
|
||||
instance.data = new_data
|
||||
modified.append(instance)
|
||||
if modified:
|
||||
@@ -644,7 +649,7 @@ class MenuEditor(MessageUI):
|
||||
self.bot, json.loads(self.menu.data.rawmessage), callback=self._editor_callback, callerid=self._callerid
|
||||
)
|
||||
self._slaves.append(editor)
|
||||
await editor.run(interaction)
|
||||
await editor.run(interaction, ephemeral=True)
|
||||
|
||||
# Template/Custom Menu
|
||||
@select(cls=Select, placeholder="TEMPLATE_MENU_PLACEHOLDER", min_values=1, max_values=1)
|
||||
@@ -821,20 +826,15 @@ class MenuEditor(MessageUI):
|
||||
"""
|
||||
Display or update the preview message.
|
||||
"""
|
||||
args = await self.menu.make_args()
|
||||
view = await self.menu.make_view()
|
||||
if self._preview is not None:
|
||||
try:
|
||||
await self._preview.delete_original_response()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
self._preview = None
|
||||
await press.response.send_message(
|
||||
**args.send_args,
|
||||
view=view or discord.utils.MISSING,
|
||||
ephemeral=True
|
||||
)
|
||||
await press.response.defer(thinking=True, ephemeral=True)
|
||||
self._preview = press
|
||||
await self.update_preview()
|
||||
|
||||
async def preview_button_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
@@ -887,13 +887,14 @@ class MenuEditor(MessageUI):
|
||||
description=desc
|
||||
)
|
||||
await selection.edit_original_response(embed=embed)
|
||||
except discord.HTTPException:
|
||||
except discord.HTTPException as e:
|
||||
error = discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
description=t(_p(
|
||||
'ui:menu_editor|button:repost|widget:repost|error:post_failed',
|
||||
"An error ocurred while posting to {channel}. Do I have sufficient permissions?"
|
||||
)).format(channel=channel.mention)
|
||||
"An unknown error ocurred while posting to {channel}!\n"
|
||||
"**Error:** `{exception}`"
|
||||
)).format(channel=channel.mention, exception=e.text)
|
||||
)
|
||||
await selection.edit_original_response(embed=error)
|
||||
else:
|
||||
@@ -948,7 +949,7 @@ class MenuEditor(MessageUI):
|
||||
title=title, description=desc
|
||||
)
|
||||
# Send as response with the repost widget attached
|
||||
await press.response.send_message(embed=embed, view=AsComponents(repost_widget))
|
||||
await press.response.send_message(embed=embed, view=AsComponents(repost_widget), ephemeral=True)
|
||||
|
||||
async def repost_button_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
@@ -1039,7 +1040,7 @@ class MenuEditor(MessageUI):
|
||||
role_index = int(splits[i-1])
|
||||
mrole = self.menu.roles[role_index]
|
||||
|
||||
error = discord.Embed(
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
title=t(_p(
|
||||
'ui:menu_editor|error:invald_emoji|title',
|
||||
@@ -1051,7 +1052,7 @@ class MenuEditor(MessageUI):
|
||||
)).format(emoji=mrole.config.emoji.data, label=mrole.config.label.data)
|
||||
)
|
||||
await mrole.data.update(emoji=None)
|
||||
await self.channel.send(embed=error)
|
||||
await self.channel.send(embed=embed)
|
||||
|
||||
async def _redraw(self, args):
|
||||
try:
|
||||
|
||||
@@ -57,6 +57,21 @@ class RoomCog(LionCog):
|
||||
for task in self._ticker_tasks.values():
|
||||
task.cancel()
|
||||
|
||||
def get_rooms(self, guildid: int, userid: Optional[int] = None):
|
||||
"""
|
||||
Get the private rooms in the given guild, using cache.
|
||||
|
||||
If `userid` is provided, filters by rooms which the given user is a member or owner of.
|
||||
"""
|
||||
guild_rooms = self._room_cache[guildid]
|
||||
if userid:
|
||||
rooms = {
|
||||
cid: room for cid, room in guild_rooms.items() if userid in room.members or userid == room.data.ownerid
|
||||
}
|
||||
else:
|
||||
rooms = guild_rooms
|
||||
return rooms
|
||||
|
||||
async def _prepare_rooms(self, room_data: list[RoomData.Room]):
|
||||
"""
|
||||
Launch or destroy rooms from the provided room data.
|
||||
|
||||
@@ -29,12 +29,9 @@ member_overwrite = discord.PermissionOverwrite(
|
||||
)
|
||||
owner_overwrite = discord.PermissionOverwrite.from_pair(*member_overwrite.pair())
|
||||
owner_overwrite.update(
|
||||
manage_channels=True,
|
||||
manage_webhooks=True,
|
||||
manage_channels=True,
|
||||
manage_messages=True,
|
||||
create_public_threads=True,
|
||||
create_private_threads=True,
|
||||
manage_threads=True,
|
||||
move_members=True,
|
||||
)
|
||||
bot_overwrite = discord.PermissionOverwrite.from_pair(*owner_overwrite.pair())
|
||||
|
||||
@@ -233,8 +233,8 @@ class RoomUI(MessageUI):
|
||||
await submit.edit_original_response(
|
||||
content=t(_p(
|
||||
'ui:room_status|button:timer|timer_created',
|
||||
"Timer created successfully! Use `/pomodoro edit` to configure further."
|
||||
))
|
||||
"Timer created successfully! Use {edit_cmd} to configure further."
|
||||
)).format(edit_cmd=self.bot.core.mention_cmd('pomodoro edit'))
|
||||
)
|
||||
await self.refresh()
|
||||
except UserInputError:
|
||||
|
||||
@@ -13,6 +13,7 @@ from meta import LionCog, LionBot, LionContext
|
||||
from meta.logger import log_wrap
|
||||
from meta.errors import UserInputError, ResponseTimedOut
|
||||
from meta.sharding import THIS_SHARD
|
||||
from meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel
|
||||
from utils.lib import utc_now, error_embed
|
||||
from utils.ui import Confirm
|
||||
from utils.data import MULTIVALUE_IN, MEMBERS
|
||||
@@ -38,6 +39,10 @@ class ScheduleCog(LionCog):
|
||||
self.bot = bot
|
||||
self.data: ScheduleData = bot.db.load_registry(ScheduleData())
|
||||
self.settings = ScheduleSettings()
|
||||
self.monitor = ComponentMonitor(
|
||||
'ScheduleCog',
|
||||
self._monitor
|
||||
)
|
||||
|
||||
# Whether we are ready to take events
|
||||
self.initialised = asyncio.Event()
|
||||
@@ -57,12 +62,56 @@ class ScheduleCog(LionCog):
|
||||
|
||||
self.session_channels = self.settings.SessionChannels._cache
|
||||
|
||||
async def _monitor(self):
|
||||
nowid = self.nowid
|
||||
now = None
|
||||
now_lock = self.slotlock(nowid)
|
||||
if not self.initialised.is_set():
|
||||
level = StatusLevel.STARTING
|
||||
info = (
|
||||
"(STARTING) "
|
||||
"Not ready. "
|
||||
"Spawn task is {spawn}. "
|
||||
"Spawn lock is {spawn_lock}. "
|
||||
"Active slots {active}."
|
||||
)
|
||||
elif nowid not in self.active_slots:
|
||||
level = StatusLevel.UNSURE
|
||||
info = (
|
||||
"(UNSURE) "
|
||||
"Setup, but current slotid {nowid} not active. "
|
||||
"Spawn task is {spawn}. "
|
||||
"Spawn lock is {spawn_lock}. "
|
||||
"Now lock is {now_lock}. "
|
||||
"Active slots {active}."
|
||||
)
|
||||
else:
|
||||
now = self.active_slots[nowid]
|
||||
level = StatusLevel.OKAY
|
||||
info = (
|
||||
"(OK) "
|
||||
"Running current slot {now}. "
|
||||
"Spawn lock is {spawn_lock}. "
|
||||
"Now lock is {now_lock}. "
|
||||
"Active slots {active}."
|
||||
)
|
||||
data = {
|
||||
'spawn': self.spawn_task,
|
||||
'spawn_lock': self.spawn_lock,
|
||||
'active': self.active_slots,
|
||||
'nowid': nowid,
|
||||
'now_lock': now_lock,
|
||||
'now': now,
|
||||
}
|
||||
return ComponentStatus(level, info, info, data)
|
||||
|
||||
@property
|
||||
def nowid(self):
|
||||
now = utc_now()
|
||||
return time_to_slotid(now)
|
||||
|
||||
async def cog_load(self):
|
||||
self.bot.system_monitor.add_component(self.monitor)
|
||||
await self.data.init()
|
||||
|
||||
# Update the session channel cache
|
||||
|
||||
@@ -253,6 +253,12 @@ class ScheduledSession:
|
||||
overwrites = room.overwrites
|
||||
for member in members:
|
||||
mobj = guild.get_member(member.userid)
|
||||
if not mobj and not guild.chunked:
|
||||
self.bot.request_chunking_for(guild)
|
||||
try:
|
||||
mobj = await guild.fetch_member(member.userid)
|
||||
except discord.HTTPException:
|
||||
mobj = None
|
||||
if mobj:
|
||||
overwrites[mobj] = discord.PermissionOverwrite(connect=True, view_channel=True)
|
||||
try:
|
||||
@@ -297,6 +303,13 @@ class ScheduledSession:
|
||||
}
|
||||
for member in members:
|
||||
mobj = guild.get_member(member.userid)
|
||||
if not mobj and not guild.chunked:
|
||||
self.bot.request_chunking_for(guild)
|
||||
try:
|
||||
mobj = await guild.fetch_member(member.userid)
|
||||
except discord.HTTPException:
|
||||
mobj = None
|
||||
|
||||
if mobj:
|
||||
overwrites[mobj] = discord.PermissionOverwrite(connect=True, view_channel=True)
|
||||
try:
|
||||
|
||||
@@ -440,7 +440,7 @@ class TimeSlot:
|
||||
)
|
||||
|
||||
def launch(self) -> asyncio.Task:
|
||||
self.run_task = asyncio.create_task(self.run())
|
||||
self.run_task = asyncio.create_task(self.run(), name=f"TimeSlot {self.slotid}")
|
||||
return self.run_task
|
||||
|
||||
@log_wrap(action="TimeSlot Run")
|
||||
|
||||
@@ -114,8 +114,10 @@ class Shopping(LionCog):
|
||||
return
|
||||
|
||||
@cmds.hybrid_group(
|
||||
name=_p('group:shop', 'shop')
|
||||
name=_p('cmd:shop', 'shop'),
|
||||
description=_p('cmd:shop|desc', "Purchase coloures, roles, and other goodies with LionCoins.")
|
||||
)
|
||||
@appcmds.guild_only
|
||||
async def shop_group(self, ctx: LionContext):
|
||||
return
|
||||
|
||||
@@ -123,6 +125,7 @@ class Shopping(LionCog):
|
||||
name=_p('cmd:shop_open', 'open'),
|
||||
description=_p('cmd:shop_open|desc', "Open the server shop.")
|
||||
)
|
||||
@appcmds.guild_only
|
||||
async def shop_open_cmd(self, ctx: LionContext):
|
||||
"""
|
||||
Opens the shop UI for the current guild.
|
||||
@@ -188,8 +191,8 @@ class StoreManager(ui.LeoUI):
|
||||
Ask the current shop widget to redraw.
|
||||
"""
|
||||
self.page_num %= len(self.stores)
|
||||
await self.stores[self.page_num].refresh()
|
||||
await self.stores[self.page_num].redraw()
|
||||
store = self.stores[self.page_num]
|
||||
await store.refresh()
|
||||
|
||||
def make_buttons(self):
|
||||
"""
|
||||
|
||||
@@ -5,7 +5,7 @@ import discord
|
||||
from discord.ui.button import Button
|
||||
|
||||
from meta import LionBot, LionCog
|
||||
from utils import ui
|
||||
from utils.ui import MessageUI
|
||||
from babel.translator import LazyStr
|
||||
|
||||
from ..data import ShopData
|
||||
@@ -165,7 +165,7 @@ class Shop:
|
||||
return self._store_cls_(self)
|
||||
|
||||
|
||||
class Store(ui.LeoUI):
|
||||
class Store(MessageUI):
|
||||
"""
|
||||
ABC for the UI used to interact with a given shop.
|
||||
|
||||
@@ -174,7 +174,7 @@ class Store(ui.LeoUI):
|
||||
(Note that each Shop instance is specific to a single customer.)
|
||||
"""
|
||||
def __init__(self, shop: Shop, interaction: discord.Interaction, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
super().__init__(callerid=interaction.user.id, **kwargs)
|
||||
|
||||
# The shop this Store is an interface for
|
||||
# Client, shop, and customer data is taken from here
|
||||
@@ -189,36 +189,10 @@ class Store(ui.LeoUI):
|
||||
self.embed: Optional[discord.Embed] = None
|
||||
|
||||
# Current interaction to use
|
||||
self.interaction: discord.Interaction = interaction
|
||||
self._original = interaction
|
||||
|
||||
# ----- UI API -----
|
||||
def set_store_row(self, row):
|
||||
self.store_row = row
|
||||
for item in row:
|
||||
self.add_item(item)
|
||||
|
||||
async def refresh(self):
|
||||
"""
|
||||
Refresh all UI elements.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def redraw(self):
|
||||
"""
|
||||
Redraw the store UI.
|
||||
"""
|
||||
if self.interaction.is_expired():
|
||||
# This is actually possible,
|
||||
# If the user keeps using the UI,
|
||||
# but never closes it until the origin interaction expires
|
||||
raise ValueError("This interaction has expired!")
|
||||
|
||||
if self.embed is None:
|
||||
await self.refresh()
|
||||
|
||||
await self.interaction.edit_original_response(embed=self.embed, view=self)
|
||||
|
||||
async def make_embed(self):
|
||||
"""
|
||||
Embed page for this shop.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -8,12 +8,13 @@ from discord import app_commands as appcmds
|
||||
from discord.ui.select import select, Select, SelectOption
|
||||
from discord.ui.button import button, Button
|
||||
|
||||
from meta import conf
|
||||
from meta import LionCog, LionContext, LionBot
|
||||
from meta.errors import SafeCancellation
|
||||
from meta.logger import log_wrap
|
||||
from utils import ui
|
||||
from utils.lib import error_embed
|
||||
from utils.lib import error_embed, MessageArgs
|
||||
from constants import MAX_COINS
|
||||
from wards import equippable_role
|
||||
|
||||
from .. import babel
|
||||
|
||||
@@ -292,6 +293,7 @@ class ColourShop(Shop):
|
||||
)
|
||||
except discord.HTTPException:
|
||||
# Possibly Forbidden, or the role doesn't actually exist anymore (cache failure)
|
||||
# TODO: Event log
|
||||
pass
|
||||
await self.data.MemberInventory.table.delete_where(inventoryid=owned.data.inventoryid)
|
||||
|
||||
@@ -414,7 +416,8 @@ class ColourShopping(ShopCog):
|
||||
item_type=self._shop_cls_._item_type_,
|
||||
deleted=False
|
||||
)
|
||||
if len(current) >= 25:
|
||||
# Disabled because we can support more than 25 colours
|
||||
if False and len(current) >= 25:
|
||||
raise SafeCancellation(
|
||||
t(_p(
|
||||
'cmd:editshop_colours_create|error:max_colours',
|
||||
@@ -709,7 +712,7 @@ class ColourShopping(ShopCog):
|
||||
item_type=self._shop_cls_._item_type_,
|
||||
deleted=False
|
||||
)
|
||||
if len(current) >= 25:
|
||||
if False and len(current) >= 25:
|
||||
raise SafeCancellation(
|
||||
t(_p(
|
||||
'cmd:editshop_colours_add|error:max_colours',
|
||||
@@ -738,7 +741,7 @@ class ColourShopping(ShopCog):
|
||||
)
|
||||
|
||||
# Check that the author has permission to manage this role
|
||||
if not (ctx.author.guild_permissions.manage_roles and ctx.author.top_role > role):
|
||||
if not (ctx.author.guild_permissions.manage_roles):
|
||||
raise SafeCancellation(
|
||||
t(_p(
|
||||
'cmd:editshop_colours_add|error:caller_perms',
|
||||
@@ -747,6 +750,9 @@ class ColourShopping(ShopCog):
|
||||
)).format(mention=role.mention)
|
||||
)
|
||||
|
||||
# Final catch-all with more general error messages
|
||||
await equippable_role(self.bot, role, ctx.author)
|
||||
|
||||
if role.permissions.administrator:
|
||||
raise SafeCancellation(
|
||||
t(_p(
|
||||
@@ -1016,7 +1022,7 @@ class ColourShopping(ShopCog):
|
||||
item = items[0]
|
||||
|
||||
# Delete the item, respecting the delete setting.
|
||||
await self.data.ShopItem.table.update_where(itemid=item.itemid, deleted=True)
|
||||
await self.data.ShopItem.table.update_where(itemid=item.itemid).set(deleted=True)
|
||||
|
||||
if delete_role:
|
||||
role = ctx.guild.get_role(item.roleid)
|
||||
@@ -1093,6 +1099,24 @@ class ColourStore(Store):
|
||||
"""
|
||||
shop: ColourShop
|
||||
|
||||
page_len = 25
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.pagen = 0
|
||||
self.blocks = [[]]
|
||||
|
||||
@property
|
||||
def page_count(self):
|
||||
return len(self.blocks)
|
||||
|
||||
@property
|
||||
def this_page(self):
|
||||
self.pagen %= self.page_count
|
||||
return self.blocks[self.pagen]
|
||||
|
||||
# ----- UI Components -----
|
||||
@select(placeholder="SELECT_PLACEHOLDER")
|
||||
async def select_colour(self, interaction: discord.Interaction, selection: Select):
|
||||
t = self.shop.bot.translator.t
|
||||
@@ -1143,7 +1167,7 @@ class ColourStore(Store):
|
||||
selector = self.select_colour
|
||||
|
||||
# Get the list of ColourRoleItems that may be purchased
|
||||
purchasable = self.shop.purchasable()
|
||||
purchasable = [item for item in self.shop.purchasable() if item in self.this_page]
|
||||
owned = self.shop.owned()
|
||||
|
||||
option_map: dict[int, SelectOption] = {}
|
||||
@@ -1168,37 +1192,54 @@ class ColourStore(Store):
|
||||
selector.disabled = False
|
||||
selector.options = list(option_map.values())
|
||||
|
||||
async def refresh(self):
|
||||
"""
|
||||
Refresh the UI elements
|
||||
"""
|
||||
@button(emoji=conf.emojis.forward)
|
||||
async def next_page_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer()
|
||||
self.pagen += 1
|
||||
await self.refresh()
|
||||
|
||||
@button(emoji=conf.emojis.backward)
|
||||
async def prev_page_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer()
|
||||
self.pagen -= 1
|
||||
await self.refresh()
|
||||
|
||||
# ----- UI Flow -----
|
||||
async def reload(self):
|
||||
items = self.shop.items
|
||||
self.blocks = [
|
||||
items[i:i+self.page_len] for i in range(0, len(items), self.page_len)
|
||||
] or [[]]
|
||||
|
||||
async def refresh_layout(self):
|
||||
await self.select_colour_refresh()
|
||||
if not self.select_colour.options:
|
||||
self._layout = [self.store_row]
|
||||
if self.page_count > 1:
|
||||
buttons = (self.prev_page_button, *self.store_row, self.next_page_button)
|
||||
else:
|
||||
self._layout = [(self.select_colour,), self.store_row]
|
||||
buttons = self.store_row
|
||||
if not self.select_colour.options:
|
||||
self._layout = [buttons]
|
||||
else:
|
||||
self._layout = [(self.select_colour,), buttons]
|
||||
|
||||
self.embed = self.make_embed()
|
||||
|
||||
def make_embed(self):
|
||||
"""
|
||||
Embed for this shop.
|
||||
"""
|
||||
async def make_message(self) -> MessageArgs:
|
||||
t = self.shop.bot.translator.t
|
||||
owned = self.shop.owned()
|
||||
if self.shop.items:
|
||||
owned = self.shop.owned()
|
||||
page_items = self.this_page
|
||||
page_start = self.pagen * self.page_len + 1
|
||||
lines = []
|
||||
for i, item in enumerate(self.shop.items):
|
||||
for i, item in enumerate(page_items):
|
||||
if owned is not None and item.itemid == owned.itemid:
|
||||
line = t(_p(
|
||||
'ui:colourstore|embed|line:owned_item',
|
||||
"`[{j:02}]` | `{price} LC` | {mention} (You own this!)"
|
||||
)).format(j=i+1, price=item.price, mention=item.mention)
|
||||
)).format(j=i+page_start, price=item.price, mention=item.mention)
|
||||
else:
|
||||
line = t(_p(
|
||||
'ui:colourstore|embed|line:item',
|
||||
"`[{j:02}]` | `{price} LC` | {mention}"
|
||||
)).format(j=i+1, price=item.price, mention=item.mention)
|
||||
)).format(j=i+page_start, price=item.price, mention=item.mention)
|
||||
lines.append(line)
|
||||
description = '\n'.join(lines)
|
||||
else:
|
||||
@@ -1210,4 +1251,23 @@ class ColourStore(Store):
|
||||
title=t(_p('ui:colourstore|embed|title', "Colour Role Shop")),
|
||||
description=description
|
||||
)
|
||||
return embed
|
||||
if self.page_count > 1:
|
||||
footer = t(_p(
|
||||
'ui:colourstore|embed|footer:paged',
|
||||
"Page {current}/{total}"
|
||||
)).format(current=self.pagen + 1, total=self.page_count)
|
||||
embed.set_footer(text=footer)
|
||||
if owned:
|
||||
embed.add_field(
|
||||
name=t(_p(
|
||||
'ui:colourstore|embed|field:warning|name',
|
||||
"Note!"
|
||||
)),
|
||||
value=t(_p(
|
||||
'ui:colourstore|embed|field:warning|value',
|
||||
"Purchasing a new colour role will *replace* your currently colour "
|
||||
"{current} without refund!"
|
||||
)).format(current=owned.mention)
|
||||
)
|
||||
|
||||
return MessageArgs(embed=embed)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
@@ -90,8 +91,12 @@ class StatsCog(LionCog):
|
||||
)).format(loading=self.bot.config.emojis.loading),
|
||||
timestamp=utc_now(),
|
||||
)
|
||||
await ctx.interaction.response(embed=waiting_embed)
|
||||
await ctx.guild.chunk()
|
||||
await ctx.interaction.response.send_message(embed=waiting_embed)
|
||||
try:
|
||||
await asyncio.wait_for(ctx.guild.chunk(), timeout=10)
|
||||
pass
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
else:
|
||||
await ctx.interaction.response.defer(thinking=True)
|
||||
ui = LeaderboardUI(self.bot, ctx.author, ctx.guild)
|
||||
@@ -141,13 +146,7 @@ class StatsCog(LionCog):
|
||||
|
||||
# Send update ack
|
||||
if modified:
|
||||
# TODO
|
||||
description = t(_p(
|
||||
'cmd:configure_statistics|resp:success|desc',
|
||||
"Activity ranks and season leaderboard will now be measured from {season_start}."
|
||||
)).format(
|
||||
season_start=setting_season_start.formatted
|
||||
)
|
||||
description = setting_season_start.update_message
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.brand_green(),
|
||||
description=description
|
||||
|
||||
@@ -539,21 +539,21 @@ class StatsData(Registry):
|
||||
_timestamp = Timestamp()
|
||||
|
||||
@classmethod
|
||||
async def fetch_tags(self, guildid: Optional[int], userid: int):
|
||||
tags = await self.fetch_where(guildid=guildid, userid=userid)
|
||||
async def fetch_tags(cls, guildid: Optional[int], userid: int):
|
||||
tags = await cls.fetch_where(guildid=guildid, userid=userid).order_by(cls.tagid)
|
||||
if not tags and guildid is not None:
|
||||
tags = await self.fetch_where(guildid=None, userid=userid)
|
||||
tags = await cls.fetch_where(guildid=None, userid=userid)
|
||||
return [tag.tag for tag in tags]
|
||||
|
||||
@classmethod
|
||||
@log_wrap(action='set_profile_tags')
|
||||
async def set_tags(self, guildid: Optional[int], userid: int, tags: Iterable[str]):
|
||||
async with self._connector.connection() as conn:
|
||||
self._connector.conn = conn
|
||||
async def set_tags(cls, guildid: Optional[int], userid: int, tags: Iterable[str]):
|
||||
async with cls._connector.connection() as conn:
|
||||
cls._connector.conn = conn
|
||||
async with conn.transaction():
|
||||
await self.table.delete_where(guildid=guildid, userid=userid)
|
||||
await cls.table.delete_where(guildid=guildid, userid=userid)
|
||||
if tags:
|
||||
await self.table.insert_many(
|
||||
await cls.table.insert_many(
|
||||
('guildid', 'userid', 'tag'),
|
||||
*((guildid, userid, tag) for tag in tags)
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from meta import LionBot
|
||||
from gui.cards import WeeklyGoalCard, MonthlyGoalCard
|
||||
from gui.base import CardMode
|
||||
from tracking.text.data import TextTrackerData
|
||||
from modules.schedule.lib import time_to_slotid
|
||||
|
||||
from .. import logger
|
||||
from ..data import StatsData
|
||||
@@ -81,8 +82,33 @@ async def get_goals_card(
|
||||
middle_completed = (await model.user_messages_between(userid, start, end))[0]
|
||||
|
||||
# Compute schedule session progress
|
||||
# TODO
|
||||
sessions_complete = 0.5
|
||||
attendance = None
|
||||
schedule_cog = bot.get_cog('ScheduleCog')
|
||||
if schedule_cog:
|
||||
booking_model = schedule_cog.data.ScheduleSessionMember
|
||||
startid = time_to_slotid(start)
|
||||
endid = time_to_slotid(end)
|
||||
query = booking_model.table.select_where(
|
||||
booking_model.slotid >= startid,
|
||||
booking_model.slotid < endid,
|
||||
userid=userid
|
||||
)
|
||||
if guildid:
|
||||
query.where(guildid=guildid)
|
||||
|
||||
query.select(
|
||||
_booked='COUNT(*)',
|
||||
_attended='COUNT(*) FILTER (WHERE attended)',
|
||||
)
|
||||
query.with_no_adapter()
|
||||
|
||||
records = await query
|
||||
if records:
|
||||
record = records[0]
|
||||
attended = record['_attended']
|
||||
booked = record['_booked']
|
||||
if booked:
|
||||
attendance = attended / booked
|
||||
|
||||
# Get member profile
|
||||
if user:
|
||||
@@ -105,7 +131,7 @@ async def get_goals_card(
|
||||
tasks_goal=goals['task_goal'],
|
||||
studied_hours=middle_completed,
|
||||
studied_goal=middle_goal,
|
||||
attendance=sessions_complete,
|
||||
attendance=attendance,
|
||||
goals=tasks,
|
||||
date=today,
|
||||
skin={'mode': mode}
|
||||
|
||||
@@ -10,6 +10,7 @@ from tracking.text.data import TextTrackerData
|
||||
|
||||
from ..data import StatsData
|
||||
from ..lib import apply_month_offset
|
||||
from .. import logger
|
||||
|
||||
|
||||
async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int, mode: CardMode) -> MonthlyStatsCard:
|
||||
@@ -65,6 +66,7 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
|
||||
current_streak = 0
|
||||
longest_streak = 0
|
||||
else:
|
||||
first_session = first_session.astimezone(lion.timezone)
|
||||
first_day = first_session.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
# first_month = first_day.replace(day=1)
|
||||
|
||||
@@ -77,7 +79,19 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
|
||||
requests.append(day)
|
||||
|
||||
# Request times between requested days
|
||||
day_stats = await req(*reqkey, *requests)
|
||||
if len(requests) > 1:
|
||||
day_stats = await req(*reqkey, *requests)
|
||||
else:
|
||||
day_stats = []
|
||||
logger.warning(
|
||||
"Requesting monthly card with no active days. "
|
||||
f"offset={offset} "
|
||||
f"first_session={first_session} "
|
||||
f"today={today} "
|
||||
f"target_end={target_end} "
|
||||
f"userid={userid} "
|
||||
f"guildid={guildid}"
|
||||
)
|
||||
|
||||
# Compute current streak and longest streak
|
||||
current_streak = 0
|
||||
|
||||
@@ -44,7 +44,7 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int):
|
||||
if crank:
|
||||
roleid = crank.roleid
|
||||
role = guild.get_role(roleid)
|
||||
name = role.name if role else str(role.id)
|
||||
name = role.name if role else 'Unknown Rank'
|
||||
minimum = crank.required
|
||||
maximum = nrank.required if nrank else None
|
||||
rangestr = format_stat_range(rank_type, minimum, maximum)
|
||||
@@ -63,7 +63,7 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int):
|
||||
if nrank:
|
||||
roleid = nrank.roleid
|
||||
role = guild.get_role(roleid)
|
||||
name = role.name if role else str(role.id)
|
||||
name = role.name if role else 'Unknown Rank'
|
||||
minimum = nrank.required
|
||||
|
||||
guild_ranks = await ranks.get_guild_ranks(guildid)
|
||||
|
||||
@@ -94,7 +94,8 @@ class StatisticsSettings(SettingGroup):
|
||||
'guildset:season_start|long_desc',
|
||||
"Activity ranks will be determined based on tracked activity since this time, "
|
||||
"and the leaderboard will display activity since this time by default. "
|
||||
"Unset to disable seasons and use all-time statistics instead."
|
||||
"Unset to disable seasons and use all-time statistics instead.\n"
|
||||
"Provided dates and times are assumed to be in the guild `timezone`, so set this first!"
|
||||
)
|
||||
_accepts = _p(
|
||||
'guildset:season_start|accepts',
|
||||
@@ -134,7 +135,8 @@ class StatisticsSettings(SettingGroup):
|
||||
resp = t(_p(
|
||||
'guildset:season_start|set_response|set',
|
||||
"The leaderboard season and activity ranks will now count from {timestamp}. "
|
||||
"Member ranks will update when they are next active. Use {rank_cmd} to refresh immediately."
|
||||
"Member ranks will update when they are next active.\n"
|
||||
"Use {rank_cmd} and press **Refresh Member Ranks** to refresh all ranks immediately."
|
||||
)).format(
|
||||
timestamp=self.formatted,
|
||||
rank_cmd=bot.core.mention_cmd('ranks')
|
||||
@@ -143,7 +145,8 @@ class StatisticsSettings(SettingGroup):
|
||||
resp = t(_p(
|
||||
'guildset:season_start|set_response|unset',
|
||||
"The leaderboard and activity ranks will now count all-time statistics. "
|
||||
"Member ranks will update when they are next active. Use {rank_cmd} to refresh immediately."
|
||||
"Member ranks will update when they are next active.\n"
|
||||
"Use {rank_cmd} and press **Refresh Member Ranks** to refresh all ranks immediately."
|
||||
)).format(rank_cmd=bot.core.mention_cmd('ranks'))
|
||||
return resp
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@ class LeaderboardUI(StatsUI):
|
||||
# (type, period) -> (pagen -> Optional[Future[Card]])
|
||||
self.cache = {}
|
||||
|
||||
self.was_chunked: bool = guild.chunked
|
||||
|
||||
async def run(self, interaction: discord.Interaction):
|
||||
self._original = interaction
|
||||
|
||||
@@ -90,6 +92,8 @@ class LeaderboardUI(StatsUI):
|
||||
periods[LBPeriod.DAY] = lguild.today
|
||||
periods[LBPeriod.WEEK] = lguild.week_start
|
||||
periods[LBPeriod.MONTH] = lguild.month_start
|
||||
alltime = (lguild.data.first_joined_at or interaction.guild.created_at).astimezone(lguild.timezone)
|
||||
periods[LBPeriod.ALLTIME] = alltime
|
||||
self.period_starts = periods
|
||||
|
||||
self.focused = True
|
||||
@@ -134,6 +138,7 @@ class LeaderboardUI(StatsUI):
|
||||
|
||||
# Filter out members which are not in the server and unranked roles and bots
|
||||
# Usually hits cache
|
||||
self.was_chunked = self.guild.chunked
|
||||
unranked_setting = await self.bot.get_cog('StatsCog').settings.UnrankedRoles.get(self.guild.id)
|
||||
unranked_roleids = set(unranked_setting.data)
|
||||
true_leaderboard = []
|
||||
@@ -297,42 +302,42 @@ class LeaderboardUI(StatsUI):
|
||||
|
||||
@button(label="This Season", style=ButtonStyle.grey)
|
||||
async def season_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
await press.response.defer(thinking=True, ephemeral=True)
|
||||
self.current_period = LBPeriod.SEASON
|
||||
self.focused = True
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(label="Today", style=ButtonStyle.grey)
|
||||
async def day_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
await press.response.defer(thinking=True, ephemeral=True)
|
||||
self.current_period = LBPeriod.DAY
|
||||
self.focused = True
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(label="This Week", style=ButtonStyle.grey)
|
||||
async def week_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
await press.response.defer(thinking=True, ephemeral=True)
|
||||
self.current_period = LBPeriod.WEEK
|
||||
self.focused = True
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(label="This Month", style=ButtonStyle.grey)
|
||||
async def month_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
await press.response.defer(thinking=True, ephemeral=True)
|
||||
self.current_period = LBPeriod.MONTH
|
||||
self.focused = True
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(label="All Time", style=ButtonStyle.grey)
|
||||
async def alltime_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
await press.response.defer(thinking=True, ephemeral=True)
|
||||
self.current_period = LBPeriod.ALLTIME
|
||||
self.focused = True
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(emoji=conf.emojis.backward, style=ButtonStyle.grey)
|
||||
async def prev_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
await press.response.defer(thinking=True, ephemeral=True)
|
||||
self.pagen -= 1
|
||||
self.focused = False
|
||||
await self.refresh(thinking=press)
|
||||
@@ -432,28 +437,44 @@ class LeaderboardUI(StatsUI):
|
||||
"""
|
||||
Generate UI message arguments from stored data
|
||||
"""
|
||||
t = self.bot.translator.t
|
||||
chunk_warning = t(_p(
|
||||
'ui:leaderboard|chunk_warning',
|
||||
"**Note:** Could not retrieve member list from Discord, so some members may be missing. "
|
||||
"Try again in a minute!"
|
||||
))
|
||||
if self.card is not None:
|
||||
period_start = self.period_starts[self.current_period]
|
||||
header = t(_p(
|
||||
'ui:leaderboard|since',
|
||||
"Counting statistics since {timestamp}"
|
||||
)).format(timestamp=discord.utils.format_dt(period_start))
|
||||
if not self.was_chunked:
|
||||
header = '\n'.join((header, chunk_warning))
|
||||
args = MessageArgs(
|
||||
embed=None,
|
||||
content=header,
|
||||
file=self.card.as_file('leaderboard.png')
|
||||
)
|
||||
else:
|
||||
t = self.bot.translator.t
|
||||
if self.stat_type is StatType.VOICE:
|
||||
empty_description = t(_p(
|
||||
'ui:leaderboard|mode:voice|message:empty|desc',
|
||||
"There has been no voice activity in this period!"
|
||||
"There has been no voice activity since {timestamp}"
|
||||
))
|
||||
elif self.stat_type is StatType.TEXT:
|
||||
empty_description = t(_p(
|
||||
'ui:leaderboard|mode:text|message:empty|desc',
|
||||
"There has been no message activity in this period!"
|
||||
"There has been no message activity since {timestamp}"
|
||||
))
|
||||
elif self.stat_type is StatType.ANKI:
|
||||
empty_description = t(_p(
|
||||
'ui:leaderboard|mode:anki|message:empty|desc',
|
||||
"There have been no Anki cards reviewed in this period!"
|
||||
"There have been no Anki cards reviewed since {timestamp}"
|
||||
))
|
||||
empty_description = empty_description.format(
|
||||
timestamp=discord.utils.format_dt(self.period_starts[self.current_period])
|
||||
)
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title=t(_p(
|
||||
@@ -462,7 +483,11 @@ class LeaderboardUI(StatsUI):
|
||||
)),
|
||||
description=empty_description
|
||||
)
|
||||
args = MessageArgs(embed=embed, files=[])
|
||||
args = MessageArgs(
|
||||
content=chunk_warning if not self.was_chunked else None,
|
||||
embed=embed,
|
||||
files=[]
|
||||
)
|
||||
return args
|
||||
|
||||
async def refresh_components(self):
|
||||
|
||||
@@ -483,12 +483,16 @@ class WeeklyMonthlyUI(StatsUI):
|
||||
).with_connection(conn)
|
||||
|
||||
if modified:
|
||||
# If either goal type was modified, clear the rendered cache and refresh
|
||||
for page_key, (goalf, statf) in self._card_cache.items():
|
||||
# If the stat period type is the same as the current period type
|
||||
if page_key[2].period is self._stat_page.period:
|
||||
self._card_cache[page_key] = (None, statf)
|
||||
await self.refresh(thinking=interaction)
|
||||
# Check whether the UI finished while we were interacting
|
||||
if not self._stopped.done():
|
||||
# If either goal type was modified, clear the rendered cache and refresh
|
||||
for page_key, (goalf, statf) in self._card_cache.items():
|
||||
# If the stat period type is the same as the current period type
|
||||
if page_key[2].period is self._stat_page.period:
|
||||
self._card_cache[page_key] = (None, statf)
|
||||
await self.refresh(thinking=interaction)
|
||||
else:
|
||||
await interaction.delete_original_response()
|
||||
await press.response.send_modal(modal)
|
||||
|
||||
async def edit_button_refresh(self):
|
||||
|
||||
@@ -186,6 +186,17 @@ def mk_print(fp: io.StringIO) -> Callable[..., None]:
|
||||
return _print
|
||||
|
||||
|
||||
def mk_status_printer(bot, printer):
|
||||
async def _status(details=False):
|
||||
if details:
|
||||
status = await bot.system_monitor.get_overview()
|
||||
else:
|
||||
status = await bot.system_monitor.get_summary()
|
||||
printer(status)
|
||||
return status
|
||||
return _status
|
||||
|
||||
|
||||
@log_wrap(action="Code Exec")
|
||||
async def _async(to_eval: str, style='exec'):
|
||||
newline = '\n' * ('\n' in to_eval)
|
||||
@@ -202,6 +213,7 @@ async def _async(to_eval: str, style='exec'):
|
||||
scope['ctx'] = ctx = context.get()
|
||||
scope['bot'] = ctx_bot.get()
|
||||
scope['print'] = _print # type: ignore
|
||||
scope['print_status'] = mk_status_printer(scope['bot'], _print)
|
||||
|
||||
try:
|
||||
if ctx and ctx.message:
|
||||
@@ -263,10 +275,63 @@ class Exec(LionCog):
|
||||
description=_("Execute arbitrary code with Exec")
|
||||
)
|
||||
@appcmd.describe(
|
||||
string="Code to execute."
|
||||
string="Code to execute.",
|
||||
target="Cross-shard peer to async on."
|
||||
)
|
||||
async def async_cmd(self, ctx: LionContext, *, string: Optional[str] = None):
|
||||
await ExecUI(ctx, string, ExecStyle.EXEC, ephemeral=False).run()
|
||||
async def async_cmd(self, ctx: LionContext,
|
||||
string: Optional[str] = None,
|
||||
target: Optional[str] = None,
|
||||
):
|
||||
if target is not None:
|
||||
if string is None:
|
||||
try:
|
||||
ctx.interaction, string = await input(
|
||||
ctx.interaction, "Cross-shard async", "Code to execute?",
|
||||
style=discord.TextStyle.long
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return
|
||||
await ctx.interaction.response.defer(thinking=True)
|
||||
if target not in shard_talk.peers:
|
||||
# Invalid target
|
||||
embed = discord.Embed(
|
||||
description="Unknown peer {target}",
|
||||
colour=discord.Colour.brand_red(),
|
||||
)
|
||||
await ctx.interaction.edit_original_response(embed=embed)
|
||||
else:
|
||||
# Send to given target
|
||||
result = await self.talk_async(string).send(target)
|
||||
if len(result) > 1900:
|
||||
# Send as file
|
||||
with StringIO(result) as fp:
|
||||
fp.seek(0)
|
||||
file = discord.File(fp, filename=f"output-{target}.md")
|
||||
await ctx.reply(file=file)
|
||||
elif result:
|
||||
await ctx.reply(f"```md\n{result}```")
|
||||
else:
|
||||
await ctx.reply("Command completed, and had no output.")
|
||||
else:
|
||||
await ExecUI(ctx, string, ExecStyle.EXEC, ephemeral=False).run()
|
||||
|
||||
async def _peer_acmpl(self, interaction: discord.Interaction, partial: str):
|
||||
"""
|
||||
Autocomplete utility for peer targets parameters.
|
||||
"""
|
||||
appids = set(shard_talk.peers.keys())
|
||||
results = [
|
||||
appcmd.Choice(name=appid, value=appid)
|
||||
for appid in appids
|
||||
if partial.lower() in appid.lower()
|
||||
]
|
||||
if not results:
|
||||
results = [
|
||||
appcmd.Choice(name=f"No peers found matching {partial}", value=partial)
|
||||
]
|
||||
return results
|
||||
|
||||
async_cmd.autocomplete('target')(_peer_acmpl)
|
||||
|
||||
@commands.hybrid_command(
|
||||
name=_p('command', 'eval'),
|
||||
@@ -298,7 +363,7 @@ class Exec(LionCog):
|
||||
except asyncio.TimeoutError:
|
||||
return
|
||||
if ctx.interaction:
|
||||
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
|
||||
await ctx.interaction.response.defer(thinking=True)
|
||||
if target is not None:
|
||||
if target not in shard_talk.peers:
|
||||
embed = discord.Embed(description=f"Unknown peer {target}", colour=discord.Colour.red())
|
||||
@@ -323,42 +388,35 @@ class Exec(LionCog):
|
||||
await ctx.reply(file=file)
|
||||
else:
|
||||
# Send as message
|
||||
await ctx.reply(f"```md\n{output}```", ephemeral=True)
|
||||
await ctx.reply(f"```md\n{output}```")
|
||||
|
||||
@asyncall_cmd.autocomplete('target')
|
||||
async def asyncall_target_acmpl(self, interaction: discord.Interaction, partial: str):
|
||||
appids = set(shard_talk.peers.keys())
|
||||
results = [
|
||||
appcmd.Choice(name=appid, value=appid)
|
||||
for appid in appids
|
||||
if partial.lower() in appid.lower()
|
||||
]
|
||||
if not results:
|
||||
results = [
|
||||
appcmd.Choice(name=f"No peers found matching {partial}", value="None")
|
||||
]
|
||||
return results
|
||||
asyncall_cmd.autocomplete('target')(_peer_acmpl)
|
||||
|
||||
@commands.hybrid_command(
|
||||
name=_('reload'),
|
||||
description=_("Reload a given LionBot extension. Launches an ExecUI.")
|
||||
)
|
||||
@appcmd.describe(
|
||||
extension=_("Name of the extesion to reload. See autocomplete for options.")
|
||||
extension=_("Name of the extension to reload. See autocomplete for options."),
|
||||
force=_("Whether to force an extension reload even if it doesn't exist.")
|
||||
)
|
||||
@appcmd.guilds(*guild_ids)
|
||||
async def reload_cmd(self, ctx: LionContext, extension: str):
|
||||
async def reload_cmd(self, ctx: LionContext, extension: str, force: Optional[bool] = False):
|
||||
"""
|
||||
This is essentially just a friendly wrapper to reload an extension.
|
||||
It is equivalent to running "await bot.reload_extension(extension)" in eval,
|
||||
with a slightly nicer interface through the autocomplete and error handling.
|
||||
"""
|
||||
if extension not in self.bot.extensions:
|
||||
exists = (extension in self.bot.extensions)
|
||||
if not (force or exists):
|
||||
embed = discord.Embed(description=f"Unknown extension {extension}", colour=discord.Colour.red())
|
||||
await ctx.reply(embed=embed)
|
||||
else:
|
||||
# Uses an ExecUI to simplify error handling and re-execution
|
||||
string = f"await bot.reload_extension('{extension}')"
|
||||
if exists:
|
||||
string = f"await bot.reload_extension('{extension}')"
|
||||
else:
|
||||
string = f"await bot.load_extension('{extension}')"
|
||||
await ExecUI(ctx, string, ExecStyle.EVAL).run()
|
||||
|
||||
@reload_cmd.autocomplete('extension')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
|
||||
import discord
|
||||
@@ -22,8 +23,8 @@ class GuildLog(LionCog):
|
||||
embed.set_author(name="Left guild!")
|
||||
|
||||
# Add more specific information about the guild
|
||||
embed.add_field(name="Owner", value="{0.name} (ID: {0.id})".format(guild.owner), inline=False)
|
||||
embed.add_field(name="Members (cached)", value="{}".format(len(guild.members)), inline=False)
|
||||
embed.add_field(name="Owner", value="<@{}>".format(guild.owner_id), inline=False)
|
||||
embed.add_field(name="Members", value="{}".format(guild.member_count), inline=False)
|
||||
embed.add_field(name="Now studying in", value="{} guilds".format(len(self.bot.guilds)), inline=False)
|
||||
|
||||
# Retrieve the guild log channel and log the event
|
||||
@@ -35,39 +36,51 @@ class GuildLog(LionCog):
|
||||
@LionCog.listener('on_guild_join')
|
||||
@log_wrap(action="Log Guild Join")
|
||||
async def log_join_guild(self, guild: discord.Guild):
|
||||
owner = guild.owner
|
||||
try:
|
||||
await asyncio.wait_for(guild.chunk(), timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
bots = 0
|
||||
known = 0
|
||||
unknown = 0
|
||||
other_members = set(mem.id for mem in self.bot.get_all_members() if mem.guild != guild)
|
||||
# TODO: Add info about when we last joined this guild etc once we have it.
|
||||
|
||||
for member in guild.members:
|
||||
if member.bot:
|
||||
bots += 1
|
||||
elif member.id in other_members:
|
||||
known += 1
|
||||
else:
|
||||
unknown += 1
|
||||
if guild.chunked:
|
||||
bots = 0
|
||||
known = 0
|
||||
unknown = 0
|
||||
other_members = set(mem.id for mem in self.bot.get_all_members() if mem.guild != guild)
|
||||
|
||||
for member in guild.members:
|
||||
if member.bot:
|
||||
bots += 1
|
||||
elif member.id in other_members:
|
||||
known += 1
|
||||
else:
|
||||
unknown += 1
|
||||
|
||||
mem1 = "people I know" if known != 1 else "person I know"
|
||||
mem2 = "new friends" if unknown != 1 else "new friend"
|
||||
mem3 = "bots" if bots != 1 else "bot"
|
||||
mem4 = "total members"
|
||||
known = "`{}`".format(known)
|
||||
unknown = "`{}`".format(unknown)
|
||||
bots = "`{}`".format(bots)
|
||||
total = "`{}`".format(guild.member_count)
|
||||
mem_str = "{0:<5}\t{4},\n{1:<5}\t{5},\n{2:<5}\t{6}, and\n{3:<5}\t{7}.".format(
|
||||
known,
|
||||
unknown,
|
||||
bots,
|
||||
total,
|
||||
mem1,
|
||||
mem2,
|
||||
mem3,
|
||||
mem4
|
||||
)
|
||||
else:
|
||||
mem_str = (
|
||||
"`{count}` total members.\n"
|
||||
"(Could not chunk guild within `60` seconds.)"
|
||||
).format(count=guild.member_count)
|
||||
|
||||
mem1 = "people I know" if known != 1 else "person I know"
|
||||
mem2 = "new friends" if unknown != 1 else "new friend"
|
||||
mem3 = "bots" if bots != 1 else "bot"
|
||||
mem4 = "total members"
|
||||
known = "`{}`".format(known)
|
||||
unknown = "`{}`".format(unknown)
|
||||
bots = "`{}`".format(bots)
|
||||
total = "`{}`".format(guild.member_count)
|
||||
mem_str = "{0:<5}\t{4},\n{1:<5}\t{5},\n{2:<5}\t{6}, and\n{3:<5}\t{7}.".format(
|
||||
known,
|
||||
unknown,
|
||||
bots,
|
||||
total,
|
||||
mem1,
|
||||
mem2,
|
||||
mem3,
|
||||
mem4
|
||||
)
|
||||
created = "<t:{}>".format(int(guild.created_at.timestamp()))
|
||||
|
||||
embed = discord.Embed(
|
||||
@@ -77,7 +90,7 @@ class GuildLog(LionCog):
|
||||
)
|
||||
embed.set_author(name="Joined guild!")
|
||||
|
||||
embed.add_field(name="Owner", value="{0} (ID: {0.id})".format(owner), inline=False)
|
||||
embed.add_field(name="Owner", value="<@{}>".format(guild.owner_id), inline=False)
|
||||
embed.add_field(name="Created at", value=created, inline=False)
|
||||
embed.add_field(name="Members", value=mem_str, inline=False)
|
||||
embed.add_field(name="Now studying in", value="{} guilds".format(len(self.bot.guilds)), inline=False)
|
||||
|
||||
@@ -173,7 +173,17 @@ class TasklistCog(LionCog):
|
||||
if not channel.guild:
|
||||
return True
|
||||
channels = (await self.settings.tasklist_channels.get(channel.guild.id)).value
|
||||
return (channel in channels) or (channel.category in channels)
|
||||
# Also allow private rooms
|
||||
roomcog = self.bot.get_cog('RoomCog')
|
||||
if roomcog:
|
||||
private_rooms = roomcog.get_rooms(channel.guild.id)
|
||||
private_channels = {room.data.channelid for room in private_rooms.values()}
|
||||
else:
|
||||
logger.warning(
|
||||
"Fetching tasklist channels before private room cog is loaded!"
|
||||
)
|
||||
private_channels = {}
|
||||
return (channel in channels) or (channel.id in private_channels) or (channel.category in channels)
|
||||
|
||||
async def call_tasklist(self, interaction: discord.Interaction):
|
||||
await interaction.response.defer(thinking=True, ephemeral=True)
|
||||
@@ -184,9 +194,28 @@ class TasklistCog(LionCog):
|
||||
tasklist = await Tasklist.fetch(self.bot, self.data, userid)
|
||||
|
||||
if await self.is_tasklist_channel(channel):
|
||||
tasklistui = TasklistUI.fetch(tasklist, channel, guild, timeout=None)
|
||||
await tasklistui.summon(force=True)
|
||||
await interaction.delete_original_response()
|
||||
# Check we have permissions to send a regular message here
|
||||
my_permissions = channel.permissions_for(guild.me)
|
||||
if not my_permissions.embed_links or not my_permissions.send_messages:
|
||||
t = self.bot.translator.t
|
||||
error = discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
title=t(_p(
|
||||
'summon_tasklist|error:insufficient_perms|title',
|
||||
"Uh-Oh, I cannot do that here!"
|
||||
)),
|
||||
description=t(_p(
|
||||
'summon_tasklist|error:insufficient_perms|desc',
|
||||
"This channel is configured as a tasklist channel, "
|
||||
"but I lack the `EMBED_LINKS` or `SEND_MESSAGES` permission here! "
|
||||
"If you believe this is unintentional, please contact a server administrator."
|
||||
))
|
||||
)
|
||||
await interaction.edit_original_response(embed=error)
|
||||
else:
|
||||
tasklistui = TasklistUI.fetch(tasklist, channel, guild, timeout=None)
|
||||
await tasklistui.summon(force=True)
|
||||
await interaction.delete_original_response()
|
||||
else:
|
||||
# Note that this will also close any existing listening tasklists in this channel (for this user)
|
||||
tasklistui = TasklistUI.fetch(tasklist, channel, guild, timeout=600)
|
||||
@@ -212,14 +241,18 @@ class TasklistCog(LionCog):
|
||||
|
||||
# Now do the rest of the listening channels
|
||||
listening = TasklistUI._live_[userid]
|
||||
for cid, ui in listening.items():
|
||||
for cid, ui in list(listening.items()):
|
||||
if channel and channel.id == cid:
|
||||
# We already did this channel
|
||||
continue
|
||||
if cid not in listening:
|
||||
# UI closed while we were updating
|
||||
continue
|
||||
try:
|
||||
await ui.refresh()
|
||||
await ui.redraw()
|
||||
except discord.HTTPException:
|
||||
await tui.close()
|
||||
await ui.close()
|
||||
|
||||
@cmds.hybrid_command(
|
||||
name=_p('cmd:tasklist', "tasklist"),
|
||||
@@ -289,6 +322,16 @@ class TasklistCog(LionCog):
|
||||
appcmds.Choice(name=task_string, value=label)
|
||||
for label, task_string in matching
|
||||
]
|
||||
elif multi and partial.lower().strip() in ('-', 'all'):
|
||||
options = [
|
||||
appcmds.Choice(
|
||||
name=t(_p(
|
||||
'argtype:taskid|match:all',
|
||||
"All tasks"
|
||||
)),
|
||||
value='-'
|
||||
)
|
||||
]
|
||||
elif multi and (',' in partial or '-' in partial):
|
||||
# Try parsing input as a multi-list
|
||||
try:
|
||||
@@ -672,7 +715,7 @@ class TasklistCog(LionCog):
|
||||
@appcmds.describe(
|
||||
taskidstr=_p(
|
||||
'cmd:tasks_remove|param:taskidstr|desc',
|
||||
"List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)."
|
||||
"List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-), or `-` to remove all."
|
||||
),
|
||||
created_before=_p(
|
||||
'cmd:tasks_remove|param:created_before|desc',
|
||||
@@ -718,10 +761,10 @@ class TasklistCog(LionCog):
|
||||
if not taskids:
|
||||
# Explicitly error if none of the ranges matched
|
||||
await ctx.interaction.edit_original_response(
|
||||
embed=error_embed(
|
||||
embed=error_embed(t(_p(
|
||||
'cmd:tasks_remove_cmd|error:no_matching',
|
||||
"No tasks on your tasklist match `{input}`"
|
||||
).format(input=taskidstr)
|
||||
))).format(input=taskidstr)
|
||||
)
|
||||
return
|
||||
|
||||
@@ -742,10 +785,10 @@ class TasklistCog(LionCog):
|
||||
tasks = await self.data.Task.fetch_where(*conditions, userid=ctx.author.id)
|
||||
if not tasks:
|
||||
await ctx.interaction.edit_original_response(
|
||||
embed=error_embed(
|
||||
embed=error_embed(t(_p(
|
||||
'cmd:tasks_remove_cmd|error:no_matching',
|
||||
"No tasks on your tasklist matching all the given conditions!"
|
||||
).format(input=taskidstr)
|
||||
))).format(input=taskidstr)
|
||||
)
|
||||
return
|
||||
taskids = [task.taskid for task in tasks]
|
||||
@@ -785,7 +828,7 @@ class TasklistCog(LionCog):
|
||||
@appcmds.describe(
|
||||
taskidstr=_p(
|
||||
'cmd:tasks_tick|param:taskidstr|desc',
|
||||
"List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)."
|
||||
"List of task numbers or ranges to tick (e.g. 1, 2, 5-7, 8.1-3, 9-) or '-' to tick all."
|
||||
),
|
||||
cascade=_p(
|
||||
'cmd:tasks_tick|param:cascade|desc',
|
||||
@@ -813,10 +856,10 @@ class TasklistCog(LionCog):
|
||||
if not taskids:
|
||||
# Explicitly error if none of the ranges matched
|
||||
await ctx.interaction.edit_original_response(
|
||||
embed=error_embed(
|
||||
embed=error_embed(t(_p(
|
||||
'cmd:tasks_remove_cmd|error:no_matching',
|
||||
"No tasks on your tasklist match `{input}`"
|
||||
).format(input=taskidstr)
|
||||
))).format(input=taskidstr)
|
||||
)
|
||||
return
|
||||
|
||||
@@ -861,7 +904,7 @@ class TasklistCog(LionCog):
|
||||
@appcmds.describe(
|
||||
taskidstr=_p(
|
||||
'cmd:tasks_untick|param:taskidstr|desc',
|
||||
"List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)."
|
||||
"List of task numbers or ranges to untick (e.g. 1, 2, 5-7, 8.1-3, 9-) or '-' to untick all."
|
||||
),
|
||||
cascade=_p(
|
||||
'cmd:tasks_untick|param:cascade|desc',
|
||||
@@ -888,10 +931,10 @@ class TasklistCog(LionCog):
|
||||
if not taskids:
|
||||
# Explicitly error if none of the ranges matched
|
||||
await ctx.interaction.edit_original_response(
|
||||
embed=error_embed(
|
||||
embed=error_embed(t(_p(
|
||||
'cmd:tasks_remove_cmd|error:no_matching',
|
||||
"No tasks on your tasklist match `{input}`"
|
||||
).format(input=taskidstr)
|
||||
))).format(input=taskidstr)
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -233,6 +233,9 @@ class Tasklist:
|
||||
|
||||
May raise `UserInputError`.
|
||||
"""
|
||||
if labelstr.strip().lower() in ('-', 'all'):
|
||||
return list(self.tasklist.keys())
|
||||
|
||||
labelmap = {label: task.taskid for label, task in self.labelled.items()}
|
||||
|
||||
splits = labelstr.split(',')
|
||||
|
||||
@@ -381,9 +381,10 @@ class TasklistUI(BasePager):
|
||||
def _format_parent(self, parentid) -> str:
|
||||
parentstr = ''
|
||||
if parentid is not None:
|
||||
task = self.tasklist.tasklist.get(parentid, None)
|
||||
if task:
|
||||
parent_label = self.tasklist.format_label(self.tasklist.labelid(parentid)).strip('.')
|
||||
pair = next(((label, task) for label, task in self.labelled.items() if task.taskid == parentid), None)
|
||||
if pair is not None:
|
||||
label, task = pair
|
||||
parent_label = self.tasklist.format_label(label).strip('.')
|
||||
parentstr = f"{parent_label}: {task.content}"
|
||||
return parentstr
|
||||
|
||||
@@ -561,8 +562,8 @@ class TasklistUI(BasePager):
|
||||
label=self.tasklist.format_label(rootlabel).strip('.'),
|
||||
)
|
||||
children = {
|
||||
label: taskid
|
||||
for label, taskid in labelled.items()
|
||||
label: task
|
||||
for label, task in labelled.items()
|
||||
if all(i == j for i, j in zip(label, rootlabel))
|
||||
}
|
||||
this_page = self.this_page
|
||||
@@ -572,11 +573,13 @@ class TasklistUI(BasePager):
|
||||
else:
|
||||
# Only show the children which display
|
||||
page_children = [
|
||||
(label, tid) for label, tid in this_page if label in children and tid != rootid
|
||||
(label, task) for label, task in this_page if label in children and task.taskid != rootid
|
||||
][:24]
|
||||
if page_children:
|
||||
block = [(rootlabel, rootid), *page_children]
|
||||
# Always add the root task
|
||||
block = [(rootlabel, self.tasklist.tasklist[rootid]), *page_children]
|
||||
else:
|
||||
# There are no subtree children on the current page
|
||||
block = []
|
||||
# Special case if the subtree is exactly the same as the page
|
||||
if not (len(block) == len(this_page) and all(i[0] == j[0] for i, j in zip(block, this_page))):
|
||||
|
||||
Reference in New Issue
Block a user