diff --git a/config/emojis.conf b/config/emojis.conf index ac156b25..2cda4f8d 100644 --- a/config/emojis.conf +++ b/config/emojis.conf @@ -47,6 +47,7 @@ loading = <:cyclebigger:975880828611600404> tick = :✅: clock = :⏱️: warning = :⚠️: +config = :⚙️: coin = <:coin:975880967485022239> diff --git a/data/migration/v12-13/migration.sql b/data/migration/v12-13/migration.sql index 4d07d399..7d3b59c1 100644 --- a/data/migration/v12-13/migration.sql +++ b/data/migration/v12-13/migration.sql @@ -731,7 +731,6 @@ CREATE TABLE season_stats( -- }}} -- Pomodoro Data {{{ - ALTER TABLE timers ADD COLUMN ownerid BIGINT REFERENCES user_config; ALTER TABLE timers ADD COLUMN manager_roleid BIGINT; ALTER TABLE timers ADD COLUMN last_messageid BIGINT; @@ -739,6 +738,64 @@ ALTER TABLE timers ADD COLUMN voice_alerts BOOLEAN; ALTER TABLE timers ADD COLUMN auto_restart BOOLEAN; ALTER TABLE timers RENAME COLUMN text_channelid TO notification_channelid; ALTER TABLE timers ALTER COLUMN last_started DROP NOT NULL; +-- }}} + +-- Rented Room Data {{{ +/* OLD SCHEMA +CREATE TABLE rented( + channelid BIGINT PRIMARY KEY, + guildid BIGINT NOT NULL, + ownerid BIGINT NOT NULL, + expires_at TIMESTAMP DEFAULT ((now() at time zone 'utc') + INTERVAL '1 day'), + created_at TIMESTAMP DEFAULT (now() at time zone 'utc'), + FOREIGN KEY (guildid, ownerid) REFERENCES members (guildid, userid) ON DELETE CASCADE +); +CREATE UNIQUE INDEX rented_owners ON rented (guildid, ownerid); + +CREATE TABLE rented_members( + channelid BIGINT NOT NULL REFERENCES rented(channelid) ON DELETE CASCADE, + userid BIGINT NOT NULL +); +CREATE INDEX rented_members_channels ON rented_members (channelid); +CREATE INDEX rented_members_users ON rented_members (userid); +*/ + +/* NEW SCHEMA +CREATE TABLE rented_rooms( + channelid BIGINT PRIMARY KEY, + guildid BIGINT NOT NULL, + ownerid BIGINT NOT NULL, + coin_balance INTEGER NOT NULL DEFAULT 0, + name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_tick TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + FOREIGN KEY (guildid, ownerid) REFERENCES members (guildid, userid) ON DELETE CASCADE +); +CREATE UNIQUE INDEX rented_owners ON rented (guildid, ownerid); + +CREATE TABLE rented_members( + channelid BIGINT NOT NULL REFERENCES rented(channelid) ON DELETE CASCADE, + userid BIGINT NOT NULL, + contribution INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX rented_members_channels ON rented_members (channelid); +CREATE INDEX rented_members_users ON rented_members (userid); +*/ + +ALTER TABLE rented RENAME TO rented_rooms; +ALTER TABLE rented_rooms DROP COLUMN expires_at; +ALTER TABLE rented_rooms ALTER COLUMN created_at TYPE TIMESTAMPTZ; +ALTER TABLE rented_rooms ADD COLUMN deleted_at TIMESTAMPTZ; +ALTER TABLE rented_rooms ADD COLUMN coin_balance INTEGER NOT NULL DEFAULT 0; +ALTER TABLE rented_rooms ADD COLUMN name TEXT; +ALTER TABLE rented_rooms ADD COLUMN last_tick TIMESTAMPTZ; +ALTER TABLE rented_members ADD COLUMN contribution INTEGER NOT NULL DEFAULT 0; + +DROP INDEX rented_owners; +CREATE INDEX rented_owners ON rented_rooms(guildid, ownerid); + +ALTER TABLE guild_config ADD COLUMN renting_visible BOOLEAN; -- }}} diff --git a/src/core/data.py b/src/core/data.py index 069ae37b..6f409332 100644 --- a/src/core/data.py +++ b/src/core/data.py @@ -196,6 +196,7 @@ class CoreData(Registry, name="core"): renting_cap = Integer() renting_role = Integer() renting_sync_perms = Bool() + renting_visible = Bool() accountability_category = Integer() accountability_lobby = Integer() diff --git a/src/data/models.py b/src/data/models.py index b63a1544..36ef7ff4 100644 --- a/src/data/models.py +++ b/src/data/models.py @@ -256,11 +256,11 @@ class RowModel: return cls.table.fetch_rows_where(*args, **kwargs) @classmethod - async def fetch(cls: Type[RowT], *rowid) -> Optional[RowT]: + async def fetch(cls: Type[RowT], *rowid, cached=True) -> Optional[RowT]: """ Fetch the row with the given id, retrieving from cache where possible. """ - row = cls._cache_.get(rowid, None) + row = cls._cache_.get(rowid, None) if cached else None if row is None: rows = await cls.fetch_where(**cls._dict_from_id(rowid)) row = rows[0] if rows else None diff --git a/src/modules/__init__.py b/src/modules/__init__.py index ee0b0249..f7a2d6b9 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -11,6 +11,7 @@ active = [ '.tasklist', '.statistics', '.pomodoro', + '.rooms', '.test', ] diff --git a/src/modules/economy/cog.py b/src/modules/economy/cog.py index 127e294c..e8cd4223 100644 --- a/src/modules/economy/cog.py +++ b/src/modules/economy/cog.py @@ -12,6 +12,7 @@ from data import ORDER from utils.ui import Confirm, Pager from utils.lib import error_embed, MessageArgs, utc_now from wards import low_management +from constants import MAX_COINS from . import babel, logger from .data import EconomyData, TransactionType, AdminActionType @@ -21,9 +22,6 @@ from .settingui import EconomyConfigUI _, _p, _np = babel._, babel._p, babel._np -MAX_COINS = 2**16 - - class Economy(LionCog): """ Commands diff --git a/src/modules/pomodoro/cog.py b/src/modules/pomodoro/cog.py index 2b7f9a01..64c6479e 100644 --- a/src/modules/pomodoro/cog.py +++ b/src/modules/pomodoro/cog.py @@ -251,7 +251,7 @@ class TimerCog(LionCog): @LionCog.listener('on_guildset_pomodoro_channel') @log_wrap(action='Update Pomodoro Channels') - async def _update_pomodoro_channels(self, guildid: int, setting: TimerSettings.PomodoroChannel): + async def _update_pomodoro_channels(self, guildid: int, data: Optional[int]): """ Request a send_status for all guild timers which need to move channel. """ diff --git a/src/modules/pomodoro/ui/__init__.py b/src/modules/pomodoro/ui/__init__.py index bccf7665..0cd9e13e 100644 --- a/src/modules/pomodoro/ui/__init__.py +++ b/src/modules/pomodoro/ui/__init__.py @@ -1 +1,3 @@ from .status import TimerStatusUI +from .edit import TimerEditor +from .config import TimerOptionsUI diff --git a/src/modules/pomodoro/ui/config.py b/src/modules/pomodoro/ui/config.py index cc448bb6..369cbd61 100644 --- a/src/modules/pomodoro/ui/config.py +++ b/src/modules/pomodoro/ui/config.py @@ -289,7 +289,7 @@ class TimerOptionsUI(MessageUI): self.refresh_delete_button(), ) self.set_layout( - (self.edit_button, self.voice_button, self.close_button) + (self.edit_button, self.voice_button, self.delete_button, self.close_button) ) else: await asyncio.gather( diff --git a/src/modules/rooms/__init__.py b/src/modules/rooms/__init__.py new file mode 100644 index 00000000..428080f0 --- /dev/null +++ b/src/modules/rooms/__init__.py @@ -0,0 +1,10 @@ +import logging +from babel.translator import LocalBabel + +logger = logging.getLogger('Rooms') +babel = LocalBabel('rooms') + + +async def setup(bot): + from .cog import RoomCog + await bot.add_cog(RoomCog(bot)) diff --git a/src/modules/rooms/cog.py b/src/modules/rooms/cog.py new file mode 100644 index 00000000..25c0e353 --- /dev/null +++ b/src/modules/rooms/cog.py @@ -0,0 +1,941 @@ +from typing import Optional +from collections import defaultdict +import asyncio + +import discord +from discord.ext import commands as cmds +from discord import app_commands as appcmds +from discord.app_commands import Range + +from meta import LionCog, LionBot, LionContext +from meta.logger import log_wrap +from meta.errors import ResponseTimedOut +from meta.sharding import THIS_SHARD +from utils.lib import utc_now, error_embed +from utils.ui import Confirm +from constants import MAX_COINS +from core.data import CoreData + +from wards import low_management + +from . import babel, logger +from .data import RoomData +from .settings import RoomSettings +from .settingui import RoomSettingUI +from .room import Room +from .roomui import RoomUI +from .lib import parse_members, owner_overwrite, member_overwrite + +_p, _np = babel._p, babel._np + + +class RoomCog(LionCog): + def __init__(self, bot: LionBot): + self.bot = bot + self.data = bot.db.load_registry(RoomData()) + self.settings = RoomSettings() + + self.ready = False + self.event_lock = asyncio.Lock() + self._room_cache = defaultdict(dict) # Map guildid -> channelid -> Room + self._ticker_tasks = {} # Map channelid -> room run task + + async def cog_load(self): + await self.data.init() + + for setting in self.settings.model_settings: + self.bot.core.guild_config.register_model_setting(setting) + + configcog = self.bot.get_cog('ConfigCog') + self.crossload_group(self.configure_group, configcog.configure_group) + + if self.bot.is_ready(): + await self.initialise() + + async def cog_unload(self): + # Cancel room tick loops + for task in self._ticker_tasks.values(): + task.cancel() + + async def _prepare_rooms(self, room_data: list[RoomData.Room]): + """ + Launch or destroy rooms from the provided room data. + + Client cache MUST be initialised, or rooms will be destroyed. + """ + # Launch or destroy rooms for the given data rows + to_delete = [] + to_launch = [] + lguildids = set() + for row in room_data: + channel = self.bot.get_channel(row.channelid) + if channel is None: + to_delete.append(row.channelid) + else: + lguildids.add(row.guildid) + to_launch.append(row) + + if to_delete: + now = utc_now() + await self.data.Room.table.update_where(channelid=to_delete).set(deleted_at=now) + room_list = ', '.join(map(str, to_delete)) + logger.info( + f"Deleted {len(to_delete)} private rooms with no underlying channel: {room_list}" + ) + + if to_launch: + lguilds = await self.bot.core.lions.fetch_guilds(*lguildids) + member_data = await self.data.RoomMember.fetch_where(channelid=[r.channelid for r in to_launch]) + member_map = defaultdict(list) + for row in member_data: + member_map[row.channelid].append(row.userid) + for row in to_launch: + room = Room(self.bot, row, lguilds[row.guildid], member_map[row.channelid]) + self._start(room) + + logger.info( + f"Launched ticker tasks for {len(to_launch)} private rooms." + ) + + def _start(self, room: Room): + task = asyncio.create_task(self._ticker(room)) + key = room.data.channelid + self._ticker_tasks[key] = task + task.add_done_callback(lambda fut: self._ticker_tasks.pop(key, None)) + + async def _ticker(self, room: Room): + cache = self._room_cache + cache[room.data.guildid][room.data.channelid] = room + try: + await room.run() + except asyncio.CancelledError: + pass + except Exception: + logger.exception( + f"Unhandled exception during room run task. This should not happen! {room.data!r}" + ) + finally: + cache[room.data.guildid].pop(room.data.channelid) + + # ----- Event Handlers ----- + @LionCog.listener('on_ready') + @log_wrap(action='Init Rooms') + async def initialise(self): + """ + Restore rented channels. + """ + async with self.event_lock: + # Cancel any running tickers, we will recreate them + for task in self._ticker_tasks.values(): + task.cancel() + + room_data = await self.data.Room.fetch_where(THIS_SHARD, deleted_at=None) + await self._prepare_rooms(room_data) + + logger.info( + f"Private Room system initialised with {len(self._ticker_tasks)} running rooms." + ) + + @LionCog.listener('on_guild_remove') + @log_wrap(action='Destroy Guild Rooms') + async def _unload_guild_rooms(self, guild: discord.Guild): + if guild.id in self._room_cache: + rooms = list(self._room_cache[guild.id].values()) + for room in rooms: + await room.destroy("Guild Removed") + logger.info( + f"Deleted {len(rooms)} private rooms after leaving guild." + ) + + # Channel delete event handler + @LionCog.listener('on_guild_channel_delete') + @log_wrap(action='Destroy Channel Room') + async def _destroy_channel_room(self, channel: discord.abc.GuildChannel): + room = self._room_cache[channel.guild.id].get(channel.id, None) + if room is not None: + await room.destroy(reason="Underlying Channel Deleted") + + # Setting event handlers + @LionCog.listener('on_guildset_rooms_category') + @log_wrap(action='Update Rooms Category') + async def _update_rooms_category(self, guildid: int, data: Optional[int]): + """ + Move all active private channels to the new category. + + This shouldn't affect the channel function at all. + """ + guild = self.bot.get_guild(guildid) + new_category = guild.get_channel(data) if guild else None + if new_category: + tasks = [] + for room in list(self._room_cache[guildid].values()): + if (channel := room.channel) is not None and channel.category != new_category: + tasks.append(channel.edit(category=new_category)) + if tasks: + try: + await asyncio.gather(*tasks) + except Exception: + logger.exception( + "Unhandled exception updating private room category." + ) + + @LionCog.listener('on_guildset_rooms_visible') + @log_wrap(action='Update Rooms Visibility') + async def _update_rooms_visibility(self, guildid: int, data: bool): + """ + Update the everyone override on each room to reflect the new setting. + """ + tasks = [] + for room in list(self._room_cache[guildid].values()): + if room.channel: + tasks.append( + room.channel.set_permissions( + room.channel.guild.default_role, + view_channel=data + ) + ) + if tasks: + try: + await asyncio.gather(*tasks) + except Exception: + logger.exception( + "Unhandled exception updating private room visibility!" + ) + + # ----- Room API ----- + @log_wrap(action="Create Room") + async def create_private_room(self, + guild: discord.Guild, owner: discord.Member, + initial_balance: int, name: str, members: list[discord.Member] + ) -> Room: + """ + Create a new private room. + """ + lguild = await self.bot.core.lions.fetch_guild(guild.id) + + # TODO: Consider extending invites to members rather than giving them immediate access + # Potential for abuse in moderation-free channel a member can add anyone too + everyone_overwrite = discord.PermissionOverwrite( + view_channel=lguild.config.get(RoomSettings.Visible.setting_id).value + ) + + # Build permission overwrites for owner and members, take into account visible setting + overwrites = { + owner: owner_overwrite, + guild.default_role: everyone_overwrite + } + for member in members: + overwrites[member] = member_overwrite + + # Create channel + channel = await guild.create_voice_channel( + name=name, + reason=f"Creating Private Room for {owner.id}", + category=lguild.config.get(RoomSettings.Category.setting_id).value, + overwrites=overwrites + ) + try: + # Create Room + now = utc_now() + data = await self.data.Room.create( + channelid=channel.id, + guildid=guild.id, + ownerid=owner.id, + coin_balance=initial_balance, + name=name, + created_at=now, + last_tick=now + ) + if members: + await self.data.RoomMember.table.insert_many( + ('channelid', 'userid'), + *((channel.id, member.id) for member in members) + ) + room = Room( + self.bot, + data, + lguild, + [member.id for member in members] + ) + self._start(room) + + # Send tips message + await channel.send("{mention} welcome to your private room! (TBD TIPS HERE)".format(mention=owner.mention)) + + # Send config UI + ui = RoomUI(self.bot, room, callerid=owner.id, timeout=None) + await ui.send(channel) + except Exception: + try: + await channel.delete(reason="Failed to created private room") + except discord.HTTPException: + pass + logger.exception( + "Unhandled exception occurred while trying to create a new private room!" + ) + raise + else: + logger.info( + f"New private room created: {room.data!r}" + ) + + return room + + async def destroy_private_room(self, room: Room, reason: Optional[str] = None): + """ + Delete a private room. + + Since this destroys the room, it will automatically remove itself from the running cache. + """ + await room.destroy(reason=reason) + + def get_channel_room(self, channelid: int) -> Optional[Room]: + """ + Get a private room if it exists in the given channel. + """ + channel = self.bot.get_channel(channelid) + if channel: + room = self._room_cache[channel.guild.id].get(channelid, None) + return room + + def get_owned_room(self, guildid: int, userid: int) -> Optional[Room]: + """ + Get a private room owned by the given member, if it exists. + """ + return next( + (room for channel, room in self._room_cache[guildid].items() if room.data.ownerid == userid), + None + ) + + # ----- Room Commands ----- + @cmds.hybrid_group( + name=_p('cmd:room', "room"), + description=_p('cmd:room|desc', "Base command group for private room configuration.") + ) + @appcmds.guild_only() + async def room_group(self, ctx: LionContext): + ... + + @room_group.command( + name=_p('cmd:room_rent', "rent"), + description=_p( + 'cmd:room_rent|desc', + "Rent a private voice channel with LionCoins." + ) + ) + @appcmds.rename( + days=_p('cmd:room_rent|param:days', "days"), + members=_p('cmd:room_rent|param:members', "members"), + name=_p('cmd:room_rent|param:name', "name"), + ) + @appcmds.describe( + days=_p( + 'cmd:room_rent|param:days|desc', + "Number of days to pre-purchase. (Default: 1)" + ), + members=_p( + 'cmd:room_rent|param:members|desc', + "Mention the members you want to add to your private room." + ), + name=_p( + 'cmd:room_rent|param:name|desc', + "Name of your private voice channel." + ) + ) + async def room_rent_cmd(self, ctx: LionContext, + days: Optional[Range[int, 1, 30]] = 1, + members: Optional[str] = None, + name: Optional[Range[str, 1, 100]] = None,): + t = self.bot.translator.t + if not ctx.guild or not ctx.interaction: + return + + # Check renting is set up, with permissions + category: discord.CategoryChannel = ctx.lguild.config.get(RoomSettings.Category.setting_id).value + if category is None: + await ctx.reply( + embed=error_embed( + t(_p( + 'cmd:room_rent|error:not_setup', + "The private room system has not been set up! " + "A private room category needs to be set first with `/configure rooms`." + )) + ), ephemeral=True + ) + return + if not category.permissions_for(ctx.guild.me).manage_channels: + await ctx.reply( + embed=error_embed( + t(_p( + 'cmd:room_rent|error:insufficient_perms', + "I do not have enough permissions to create a new channel under " + "the configured private room category!" + )) + ), ephemeral=True + ) + return + + # Check that the author doesn't already own a room + room = self.get_owned_room(ctx.guild.id, ctx.author.id) + if room is not None and room.channel: + await ctx.reply( + embed=error_embed( + t(_p( + 'cmd:room_rent|error:room_exists', + "You already own a private room! Click to visit: {channel}" + )).format(channel=room.channel.mention) + ), ephemeral=True + ) + return + + # Check that provided members actually exist + memberids = set(parse_members(members)) if members else set() + memberids.discard(ctx.author.id) + provided = [] + for mid in memberids: + member = ctx.guild.get_member(mid) + if not member: + try: + member = await ctx.guild.fetch_member(mid) + except discord.HTTPException: + await ctx.reply( + embed=error_embed( + t(_p( + 'cmd:room_rent|error:member_not_found', + "Could not find the requested member {mention} in this server!" + )).format(member=f"<@{mid}>") + ), ephemeral=True + ) + return + provided.append(member) + + # Check provided members don't go over cap + cap = ctx.lguild.config.get(RoomSettings.MemberLimit.setting_id).value + if len(provided) >= cap: + await ctx.reply( + embed=error_embed( + t(_p( + 'cmd:room_rent|error:too_many_members', + "Too many members! You have requested to add `{count}` members to your room, " + "but the maximum private room size is `{cap}`!" + )).format(count=len(provided), cap=cap), + ), + ephemeral=True + ) + return + + # Balance checks + rent = ctx.lguild.config.get(RoomSettings.Rent.setting_id).value + required = rent * days + # Purchase confirmation + confirm_msg = t(_np( + 'cmd:room_rent|confirm:purchase', + "Are you sure you want to spend {coin}**{required}** to " + "rent a private room for `one` day?", + "Are you sure you want to spend {coin}**{required}** to " + "rent a private room for `{days}` days?", + days + )).format( + coin=self.bot.config.emojis.coin, + required=required, + days=days + ) + confirm = Confirm(confirm_msg, ctx.author.id) + try: + result = await confirm.ask(ctx.interaction, ephemeral=True) + except ResponseTimedOut: + result = False + if not result: + return + + # Positive response. Start a transaction. + conn = await self.bot.db.get_connection() + async with conn.transaction(): + # Check member balance is sufficient + await ctx.alion.data.refresh() + member_balance = ctx.alion.data.coins + if member_balance < required: + await ctx.reply( + embed=error_embed( + t(_np( + 'cmd:room_rent|error:insufficient_funds', + "Renting a private room for `one` day costs {coin}**{required}**, " + "but you only have {coin}**{balance}**!", + "Renting a private room for `{days}` days costs {coin}**{required}**, " + "but you only have {coin}**{balance}**!", + days + )).format( + coin=self.bot.config.emojis.coin, + balance=member_balance, + required=required, + days=days + ), + ephemeral=True + ) + ) + return + + # Deduct balance + # TODO: Economy transaction instead of manual deduction + await ctx.alion.data.update(coins=CoreData.Member.coins - required) + + # Create room with given starting balance and other parameters + room = await self.create_private_room( + ctx.guild, + ctx.author, + required - rent, + name or ctx.author.display_name, + members=provided + ) + + # Ack with confirmation message pointing to the room + msg = t(_p( + 'cmd:room_rent|success', + "Successfully created your private room {channel}!" + )).format(channel=room.channel.mention) + await ctx.reply( + embed=discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p('cmd:room_rent|success|title', "Private Room Created!")), + description=msg + ) + ) + + @room_group.command( + name=_p('cmd:room_status', "status"), + description=_p( + 'cmd:room_status|desc', + "Display the status of your current room." + ) + ) + async def room_status_cmd(self, ctx: LionContext): + t = self.bot.translator.t + if not ctx.guild or not ctx.interaction: + return + + # Resolve target room + # Resolve order: Current channel, then owned room + room = self.get_channel_room(ctx.channel.id) + if room is None: + room = self.get_owned_room(ctx.guild.id, ctx.author.id) + if room is None: + await ctx.reply( + embed=error_embed(t(_p( + 'cmd:room_status|error:no_target', + "Could not identify target private room! Please re-run the command " + "in the private room you wish to view the status of." + )) + ), + ephemeral=True + ) + return + + # Respond with room UI + # Ephemeral UI unless we are in the room + ui = RoomUI(self.bot, room, callerid=ctx.author.id) + await ui.run(ctx.interaction, ephemeral=(ctx.channel.id != room.data.channelid)) + await ui.wait() + + @room_group.command( + name=_p('cmd:room_invite', "invite"), + description=_p( + 'cmd:room_invite|desc', + "Add members to your private room." + ) + ) + @appcmds.rename( + members=_p('cmd:room_invite|param:members', "members"), + ) + @appcmds.describe( + members=_p( + 'cmd:room_invite|param:members|desc', + "Mention the members you want to add." + ) + ) + async def room_invite_cmd(self, ctx: LionContext, members: str): + t = self.bot.translator.t + if not ctx.guild or not ctx.interaction: + return + + # Resolve target room + room = self.get_owned_room(ctx.guild.id, ctx.author.id) + if room is None: + await ctx.reply( + embed=error_embed(t(_p( + 'cmd:room_invite|error:no_room', + "You do not own a private room! Use `/room rent` to rent one with {coin}!" + )).format(coin=self.bot.config.emojis.coin)), + ephemeral=True + ) + return + + # Check that provided members actually exist + memberids = set(parse_members(members)) if members else set() + memberids.discard(ctx.author.id) + memberids.difference_update(room.members) + provided = [] + for mid in memberids: + member = ctx.guild.get_member(mid) + if not member: + try: + member = await ctx.guild.fetch_member(mid) + except discord.HTTPException: + await ctx.reply( + embed=error_embed( + t(_p( + 'cmd:room_invite|error:member_not_found', + "Could not find the invited member {mention} in this server!" + )).format(member=f"<@{mid}>") + ), ephemeral=True + ) + return + provided.append(member) + if not provided: + await ctx.reply( + embed=error_embed( + t(_p( + 'cmd:room_invite|error:no_new_members', + "All members mentioned are already in the room!" + )) + ), + ephemeral=True + ) + return + + # Check provided members don't go over cap + cap = ctx.lguild.config.get(RoomSettings.MemberLimit.setting_id).value + if len(room.members) + len(provided) >= cap: + await ctx.reply( + embed=error_embed( + t(_p( + 'cmd:room_invite|error:too_many_members', + "Too many members! You have invited `{count}` new members to your room, " + "but you already have `{current}`, " + "and the member cap is `{cap}`!" + )).format( + count=len(provided), + current=len(room.members) + 1, + cap=cap + ), + ), + ephemeral=True + ) + return + + await ctx.interaction.response.defer(thinking=True, ephemeral=True) + + # Finally, add the members + await room.add_new_members([m.id for m in provided]) + + # And ack + if ctx.channel.id != room.data.channelid: + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'cmd:room_invite|success|ack', + "Members Invited successfully." + )) + ) + await ctx.reply(embed=embed) + else: + await ctx.interaction.delete_original_response() + + @room_group.command( + name=_p('cmd:room_kick', "kick"), + description=_p( + 'cmd:room_kick|desc', + "Remove a members from your private room." + ) + ) + @appcmds.rename( + members=_p('cmd:room_kick|param:members', "members") + ) + @appcmds.describe( + members=_p( + 'cmd:room_kick|param:members|desc', + "Mention the members you want to remove. Also accepts space-separated user ids." + ) + ) + async def room_kick_cmd(self, ctx: LionContext, members: str): + t = self.bot.translator.t + if not ctx.guild or not ctx.interaction: + return + + # Resolve target room + room = self.get_owned_room(ctx.guild.id, ctx.author.id) + if room is None: + await ctx.reply( + embed=error_embed(t(_p( + 'cmd:room_kick|error:no_room', + "You do not own a private room! Use `/room rent` to rent one with {coin}!" + )).format(coin=self.bot.config.emojis.coin)), + ephemeral=True + ) + return + + # Only remove members which are actually in the room + # Also ignore the owner + memberids = set(parse_members(members)) if members else set() + if ctx.guild.me.id in memberids: + await ctx.reply("Ouch, what did I do?") + memberids.intersection_update(room.members) + if not memberids: + await ctx.reply( + embed=error_embed( + t(_p( + 'cmd:room_kick|error:no_matching_members', + "None of the mentioned members are in this room!" + )) + ), + ephemeral=True + ) + return + + await ctx.interaction.response.defer(thinking=True, ephemeral=True) + + # Finally, add the members + await room.rm_members(memberids) + + # And ack + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'cmd:room_kick|success|ack', + "Members removed." + )) + ) + await ctx.reply(embed=embed) + + @room_group.command( + name=_p('cmd:room_transfer', "transfer"), + description=_p( + 'cmd:room_transfer|desc', + "Transfer your private room to another room member. Not reversible!" + ) + ) + @appcmds.rename( + new_owner=_p('cmd:room_transfer|param:new_owner', "new_owner") + ) + @appcmds.describe( + new_owner=_p( + 'cmd:room_transfer|param:new_owner', + "The room member you would like to transfer your room to." + ) + ) + async def room_transfer_cmd(self, ctx: LionContext, new_owner: discord.Member): + t = self.bot.translator.t + if not ctx.guild or not ctx.interaction: + return + + # Resolve target room + room = self.get_owned_room(ctx.guild.id, ctx.author.id) + if room is None: + await ctx.reply( + embed=error_embed(t(_p( + 'cmd:room_transfer|error:no_room', + "You do not own a private room to transfer!" + )).format(coin=self.bot.config.emojis.coin)), + ephemeral=True + ) + return + + # Check if the target owner is actually a member of the room + if new_owner.id not in room.members: + await ctx.reply( + embed=error_embed( + t(_p( + 'cmd:room_transfer|error:target_not_member', + "{mention} is not a member of your private room! You must invite them first." + )).format(mention=new_owner) + ), ephemeral=True) + return + + # Check if target owner already has a room + new_owner_room = self.get_owned_room(ctx.guild.id, new_owner.id) + if new_owner_room is not None: + await ctx.reply( + embed=error_embed( + t(_p( + 'cmd:room_transfer|error:target_has_room', + "{mention} already owns a room! Members can only own one room at a time." + )).format(mention=new_owner.mention) + ), ephemeral=True + ) + return + + # Confirm transfer + confirm_msg = t(_p( + 'cmd:room_transfer|confirm|question', + "Are you sure you wish to transfer your private room {channel} to {new_owner}? " + "This action is not reversible!" + )).format(channel=room.channel, new_owner=new_owner.mention) + confirm = Confirm(confirm_msg, ctx.author.id) + try: + result = await confirm.ask(ctx.interaction, ephemeral=True) + except ResponseTimedOut: + result = False + if not result: + return + + # Finally, do the transfer + await room.transfer_ownership(new_owner) + + # Ack + await ctx.reply( + embed=discord.Embed( + colour=discord.Colour.brand_green(), + description=t(_p( + 'cmd:room_transfer|success|description', + "You have successfully transferred ownership of {channel} to {new_owner}." + )).format(channel=room.channel, new_owner=new_owner.mention) + ) + ) + + @room_group.command( + name=_p('cmd:room_deposit', "deposit"), + description=_p( + 'cmd:room_deposit|desc', + "Deposit LionCoins in your private room bank to add more days. (Members may also deposit!)" + ) + ) + @appcmds.rename( + coins=_p('cmd:room_deposit|param:coins', "coins") + ) + @appcmds.describe( + coins=_p( + 'cmd:room_deposit|param:coins|desc', + "Number of coins to deposit." + ) + ) + async def room_deposit_cmd(self, ctx: LionContext, coins: Range[int, 1, MAX_COINS]): + t = self.bot.translator.t + if not ctx.guild or not ctx.interaction: + return + + # All responses will be ephemeral + await ctx.interaction.response.defer(thinking=True, ephemeral=True) + + # Resolve target room + # Resolve order: Current channel, then owned room + room = self.get_channel_room(ctx.channel.id) + if room is None: + room = self.get_owned_room(ctx.guild.id, ctx.author.id) + if room is None: + await ctx.reply( + embed=error_embed(t(_p( + 'cmd:room_deposit|error:no_target', + "Could not identify target private room! Please re-run the command " + "in the private room you wish to contribute to." + )) + ), + ephemeral=True + ) + return + + # Start Transaction + conn = await self.bot.db.get_connection() + async with conn.transaction(): + await ctx.alion.data.refresh() + member_balance = ctx.alion.data.coins + if member_balance < coins: + await ctx.reply( + embed=error_embed(t(_p( + 'cmd:room_deposit|error:insufficient_funds', + "You cannot deposit {coin}**{amount}**! You only have {coin}**{balance}**." + )).format( + coin=self.bot.config.emojis.coin, + amount=coins, + balance=member_balance + )), + ephemeral=True + ) + return + + # Deduct balance + # TODO: Economy transaction + await ctx.alion.data.update(coins=CoreData.Member.coins - coins) + await room.data.update(coin_balance=RoomData.Room.coin_balance + coins) + + # Post deposit message + await room.notify_deposit(ctx.author, coins) + + # Ack the deposit + if ctx.channel.id != room.data.channelid: + ack_msg = t(_p( + 'cmd:room_depost|success', + "Success! You have contributed {coin}**{amount}** to the private room bank." + )).format(coin=self.bot.config.emojis.coin, amount=coins) + await ctx.reply( + embed=discord.Embed(colour=discord.Colour.brand_green(), description=ack_msg) + ) + else: + await ctx.interaction.delete_original_response() + + # ----- Guild Configuration ----- + @LionCog.placeholder_group + @cmds.hybrid_group('configure', with_app_commands=False) + async def configure_group(self, ctx: LionContext): + ... + + @configure_group.command( + name=_p('cmd:configure_rooms', "rooms"), + description=_p('cmd:configure_rooms|desc', "Configure Rented Private Rooms") + ) + @appcmds.rename( + **{setting.setting_id: setting._display_name for setting in RoomSettings.model_settings} + ) + @appcmds.describe( + **{setting.setting_id: setting._desc for setting in RoomSettings.model_settings} + ) + @cmds.check(low_management) + async def configure_rooms_cmd(self, ctx: LionContext, + rooms_category: Optional[discord.CategoryChannel] = None, + rooms_price: Optional[Range[int, 0, MAX_COINS]] = None, + rooms_slots: Optional[Range[int, 1, MAX_COINS]] = None, + rooms_visible: Optional[bool] = None): + # t = self.bot.translator.t + + # Type checking guards + if not ctx.guild: + return + if not ctx.interaction: + return + + # TODO: Value verification on the category channel for permissions + await ctx.interaction.response.defer(thinking=True) + + provided = { + 'rooms_category': rooms_category, + 'rooms_price': rooms_price, + 'rooms_slots': rooms_slots, + 'rooms_visible': rooms_visible + } + modified = {(sid, val) for sid, val in provided.items() if val is not None} + if modified: + lines = [] + update_args = {} + settings = [] + for setting_id, value in modified: + setting = ctx.lguild.config.get(setting_id) + setting.value = value + settings.append(setting) + update_args[setting._column] = setting._data + lines.append(setting.update_message) + + # Data update + await ctx.lguild.data.update(**update_args) + for setting in settings: + setting.dispatch_update() + + # Ack modified + tick = self.bot.config.emojis.tick + embed = discord.Embed( + colour=discord.Colour.brand_green(), + description='\n'.join(f"{tick} {line}" for line in lines) + ) + await ctx.reply(embed=embed) + + if ctx.channel.id not in RoomSettingUI._listening or not modified: + ui = RoomSettingUI(self.bot, ctx.guild.id, ctx.channel.id) + await ui.run(ctx.interaction) + await ui.wait() diff --git a/src/modules/rooms/data.py b/src/modules/rooms/data.py new file mode 100644 index 00000000..3237fcc9 --- /dev/null +++ b/src/modules/rooms/data.py @@ -0,0 +1,48 @@ +from data import Registry, RowModel +from data.columns import Integer, Timestamp, String + + +class RoomData(Registry): + class Room(RowModel): + """ + CREATE TABLE rented_rooms( + channelid BIGINT PRIMARY KEY, + guildid BIGINT NOT NULL, + ownerid BIGINT NOT NULL, + coin_balance INTEGER NOT NULL DEFAULT 0, + name TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + last_tick TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + FOREIGN KEY (guildid, ownerid) REFERENCES members (guildid, userid) ON DELETE CASCADE + ); + CREATE INDEX rented_owners ON rented (guildid, ownerid); + """ + _tablename_ = 'rented_rooms' + + channelid = Integer(primary=True) + guildid = Integer() + ownerid = Integer() + coin_balance = Integer() + name = String() + created_at = Timestamp() + last_tick = Timestamp() + deleted_at = Timestamp() + + class RoomMember(RowModel): + """ + Schema + ------ + CREATE TABLE rented_members( + channelid BIGINT NOT NULL REFERENCES rented(channelid) ON DELETE CASCADE, + userid BIGINT NOT NULL, + contribution INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX rented_members_channels ON rented_members (channelid); + CREATE INDEX rented_members_users ON rented_members (userid); + """ + _tablename_ = 'rented_members' + + channelid = Integer(primary=True) + userid = Integer(primary=True) + contribution = Integer() diff --git a/src/modules/rooms/lib.py b/src/modules/rooms/lib.py new file mode 100644 index 00000000..5bfda477 --- /dev/null +++ b/src/modules/rooms/lib.py @@ -0,0 +1,41 @@ +import discord +import re + + +def parse_members(memberstr: str) -> list[int]: + """ + Parse a mixed list of ids and mentions into a list of memberids. + """ + if memberstr: + memberids = [int(x) for x in re.findall(r'[<@!\s]*([0-9]{15,20})[>\s,]*', memberstr)] + else: + memberids = [] + return memberids + + +owner_overwrite = discord.PermissionOverwrite( + view_channel=True, + manage_channels=True, + manage_webhooks=True, + attach_files=True, + embed_links=True, + add_reactions=True, + manage_messages=True, + create_public_threads=True, + create_private_threads=True, + manage_threads=True, + connect=True, + speak=True, + stream=True, + use_application_commands=True, + use_embedded_activities=True, + move_members=True, + external_emojis=True +) +member_overwrite = discord.PermissionOverwrite( + view_channel=True, + send_messages=True, + connect=True, + speak=True, + stream=True +) diff --git a/src/modules/rooms/room.py b/src/modules/rooms/room.py new file mode 100644 index 00000000..23e7e817 --- /dev/null +++ b/src/modules/rooms/room.py @@ -0,0 +1,278 @@ +from typing import Optional +import asyncio +from datetime import timedelta, datetime + +import discord + +from meta import LionBot +from meta.logger import log_wrap, log_context +from utils.lib import utc_now +from core.lion_guild import LionGuild +from babel.translator import ctx_locale + +from modules.pomodoro.cog import TimerCog +from modules.pomodoro.timer import Timer + +from . import babel, logger +from .data import RoomData +from .roomui import RoomUI +from .lib import owner_overwrite, member_overwrite + +_p = babel._p + + +class Room: + __slots__ = ('bot', 'data', 'lguild', 'members', '_tick_wait') + + tick_length = timedelta(days=1) + # tick_length = timedelta(hours=1) + + def __init__(self, bot: LionBot, data: RoomData.Room, lguild: LionGuild, members: list[int]): + self.bot = bot + self.data = data + self.lguild = lguild + self.members = members + + log_context.set(f"cid: {self.data.channelid}") + + # State + self._tick_wait: Optional[asyncio.Task] = None + + @property + def channel(self) -> Optional[discord.VoiceChannel]: + """ + Discord Channel which this room lives in. + """ + return self.bot.get_channel(self.data.channelid) + + @property + def timer(self) -> Optional[Timer]: + timer_cog: TimerCog = self.bot.get_cog('TimerCog') + if timer_cog is not None: + return timer_cog.get_channel_timer(self.data.channelid) + + @property + def last_tick(self): + return self.data.last_tick or self.data.created_at + + @property + def next_tick(self): + return self.last_tick + self.tick_length + + @property + def rent(self): + return self.lguild.config.get('rooms_price').value + + @property + def expiring(self): + return self.rent > self.data.coin_balance + + @property + def deleted(self): + return bool(self.data.deleted_at) + + async def notify_deposit(self, member: discord.Member, amount: int): + # Assumes locale is set correctly + t = self.bot.translator.t + notification = discord.Embed( + colour=discord.Colour.brand_green(), + description=t(_p( + 'room|notify:deposit|description', + "{member} has deposited {coin}**{amount}** into the room bank!" + )).format(member=member.mention, coin=self.bot.config.emojis.coin, amount=amount) + ) + if self.channel: + try: + await self.channel.send(embed=notification) + except discord.HTTPException: + pass + + async def add_new_members(self, memberids): + member_data = self.bot.get_cog('RoomCog').data.RoomMember + await member_data.table.insert_many( + ('channelid', 'userid'), + *((self.data.channelid, memberid) for memberid in memberids) + ) + self.members.extend(memberids) + t = self.bot.translator.t + notification = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'room|notify:new_members|title', + "New Members!" + )), + description=t(_p( + 'room|notify:new_members|desc', + "Welcome {members}" + )).format(members=', '.join(f"<@{mid}>" for mid in memberids)) + ) + if self.channel: + try: + await self.channel.send(embed=notification) + except discord.HTTPException: + pass + + async def rm_members(self, memberids): + member_data = self.bot.get_cog('RoomCog').data.RoomMember + await member_data.table.delete_where(channelid=self.data.channelid, userid=list(memberids)) + self.members = list(set(self.members).difference(memberids)) + # No need to notify for removal + return + + async def transfer_ownership(self, new_owner): + member_data = self.bot.get_cog('RoomCog').data.RoomMember + old_ownerid = self.data.ownerid + + # Add old owner as a member + await member_data.create(channelid=self.data.channelid, userid=old_ownerid) + self.members.append(old_ownerid) + + # Remove new owner from the members + await member_data.table.delete_where(channelid=self.data.channelid, userid=new_owner.id) + self.members.remove(new_owner.id) + + # Change room owner + await self.data.update(ownerid=new_owner.id) + + if self.channel: + try: + # Update overwrite for old owner + if old_owner := self.channel.guild.get_member(old_ownerid): + await self.channel.set_permissions( + old_owner, + overwrite=member_overwrite + ) + # Update overwrite for new owner + await self.channel.set_permissions( + new_owner, + overwrite=owner_overwrite + ) + except discord.HTTPException: + logger.warning( + "Exception while changing room ownership. Room overwrites may be incorrect.", + exc_info=True + ) + # Notification + t = self.bot.translator.t + notification = discord.Embed( + colour=discord.Colour.brand_green(), + description=t(_p( + 'room|notify:transfer|description', + "{old_owner} has transferred private room ownership to {new_owner}" + )).format(old_owner=f"<@{old_ownerid}>", new_owner=new_owner.mention) + ) + try: + await self.channel.send(embed=notification) + except discord.HTTPException: + pass + + @log_wrap(action="Room Runloop") + async def run(self): + """ + Tick loop. + + Keeps scheduling ticks until expired or cancelled. + May be safely cancelled. + """ + if self._tick_wait and not self._tick_wait.done(): + self._tick_wait.cancel() + + while not self.deleted: + now = utc_now() + diff = (self.next_tick - now).total_seconds() + self._tick_wait = asyncio.create_task(asyncio.sleep(diff)) + try: + await self._tick_wait + await asyncio.shield(self._tick()) + except asyncio.CancelledError: + break + except Exception: + logger.exception( + f"Unhandled exception while ticking for room: {self.data!r}" + ) + + @log_wrap(action="Room Tick") + async def _tick(self): + """ + Execute the once-per day room tick. + + This deducts the rent amount from the room balance, + if the balance is insufficient, expires the room. + Posts a status message in the room channel when it does so. + """ + t = self.bot.translator.t + ctx_locale.set(self.lguild.config.get('guild_locale').value) + if self.deleted: + # Already deleted, nothing to do + pass + else: + # Run tick + logger.debug(f"Tick running for room: {self.data!r}") + + # Deduct balance + await self.data.update( + coin_balance=RoomData.Room.coin_balance - self.rent, + last_tick=utc_now() + ) + + # If balance is negative, expire room, otherwise notify channel + if self.data.coin_balance < 0: + if owner := self.bot.get_user(self.data.ownerid): + embed = discord.Embed( + colour=discord.Colour.red(), + title=t(_p( + 'room|embed:expiry|title', + "Private Room Expired!" + )), + description=t(_p( + 'room|embed:expiry|description', + "Your private room in **{guild}** has expired!" + )).format(guild=self.bot.get_guild(self.data.guildid)) + ) + try: + await owner.send(embed=embed) + except discord.HTTPException: + pass + await self.destroy(reason='Room Expired') + elif self.channel: + # Notify channel + embed = discord.Embed( + colour=discord.Colour.orange(), + description=self.bot.translator.t(_p( + 'room|tick|rent_deducted', + "Daily rent deducted from room balance. New balance: {coin}**{amount}**" + )).format( + coin=self.bot.config.emojis.coin, amount=self.data.coin_balance + ) + ) + try: + await self.channel.send(embed=embed) + except discord.HTTPException: + pass + else: + # No channel means room was deleted + # Just cleanup quietly + await self.destroy(reason='Channel Missing') + + @log_wrap(action="Destroy Room") + async def destroy(self, reason: Optional[str] = None): + """ + Destroy the room. + + Attempts to delete the voice channel and log destruction. + This is idempotent, so multiple events may trigger destroy. + """ + if self._tick_wait: + self._tick_wait.cancel() + + if self.channel: + try: + await self.channel.delete() + except discord.HTTPException: + pass + + if not self.deleted: + logger.info( + f"Destroying private room for reason '{reason}': {self.data!r}" + ) + await self.data.update(deleted_at=utc_now()) diff --git a/src/modules/rooms/roomui.py b/src/modules/rooms/roomui.py new file mode 100644 index 00000000..c4753270 --- /dev/null +++ b/src/modules/rooms/roomui.py @@ -0,0 +1,461 @@ +from typing import Optional, TYPE_CHECKING +import asyncio + +import discord +from discord.ui.button import button, Button, ButtonStyle +from discord.ui.select import select, UserSelect + +from meta import LionBot, conf +from meta.errors import UserInputError +from babel.translator import ctx_locale +from utils.lib import utc_now, MessageArgs, error_embed +from utils.ui import MessageUI, input +from core.data import CoreData + +from modules.pomodoro.ui import TimerOptionsUI, TimerEditor +from modules.pomodoro.lib import TimerRole +from modules.pomodoro.options import TimerOptions + +from . import babel, logger +from .data import RoomData +from .settings import RoomSettings + +if TYPE_CHECKING: + from .room import Room + + +_p = babel._p + + +class RoomUI(MessageUI): + """ + View status for and reconfigure a rented room. + + May be used by both owners and members, + but members will get a simplified UI. + """ + + def __init__(self, bot: LionBot, room: 'Room', **kwargs): + # Do we need to set the locale? + # The room never calls the UI itself, so we should always have context locale + # If this changes (e.g. persistent status), uncomment this. + # ctx_locale.set(room.lguild.config.get('guild_locale').value) + super().__init__(**kwargs) + self.bot = bot + self.room = room + + async def owner_ward(self, interaction: discord.Interaction): + t = self.bot.translator.t + if not interaction.user.id == self.room.data.ownerid: + await interaction.response.send_message( + embed=discord.Embed( + colour=discord.Colour.brand_red(), + description=t(_p( + 'ui:room_status|error:owner_required', + "You must be the private room owner to do this!" + )) + ), + ephemeral=True + ) + return False + return True + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id not in self.room.members and interaction.user.id != self.room.data.ownerid: + t = self.bot.translator.t + await interaction.response.send_message( + embed=discord.Embed( + colour=discord.Colour.brand_red(), + description=t(_p( + 'ui:room_status|error:member_required', + "You need to be a member of the private room to do this!" + )) + ), + ephemeral=True + ) + return False + return True + + @button(label='DEPOSIT_PLACEHOLDER', style=ButtonStyle.green, emoji=conf.emojis.coin) + async def desposit_button(self, press: discord.Interaction, pressed: Button): + t = self.bot.translator.t + + # Open modal, ask how much they want to deposit + try: + submit, response = await input( + press, + title=t(_p( + 'ui:room_status|button:deposit|modal:deposit|title', + "Room Deposit" + )), + question=t(_p( + 'ui:room_status|button:deposit|modal:deposit|field:question|label', + "How many LionCoins do you want to deposit?" + )), + required=True, + max_length=16 + ) + except asyncio.TimeoutError: + # Input timed out + # They probably just closed the dialogue + # Exit silently + return + + # Input checking + response = response.strip() + if not response.isdigit(): + await submit.response.send_message( + embed=error_embed( + t(_p( + 'ui:room_status|button:deposit|error:invalid_number', + "Cannot deposit `{inputted}` coins. Please enter a positive integer." + )).format(inputted=response) + ), ephemeral=True + ) + return + amount = int(response) + await submit.response.defer(thinking=True, ephemeral=True) + + # Start transaction for deposit + conn = await self.bot.db.get_connection() + async with conn.transaction(): + # Get the lion balance directly + lion = await self.bot.core.data.Member.fetch( + self.room.data.guildid, + press.user.id, + cached=False + ) + balance = lion.coins + if balance < amount: + await submit.edit_original_response( + embed=error_embed( + t(_p( + 'ui:room_status|button:deposit|error:insufficient_funds', + "You cannot deposit {coin}**{amount}**! You only have {coin}**{balance}**." + )).format( + coin=self.bot.config.emojis.coin, + amount=amount, + balance=balance + ) + ) + ) + return + # TODO: Economy Transaction + await lion.update(coins=CoreData.Member.coins - amount) + await self.room.data.update(coin_balance=RoomData.Room.coin_balance + amount) + + # Post deposit message + await self.room.notify_deposit(press.user, amount) + + await self.refresh(thinking=submit) + + async def desposit_button_refresh(self): + self.desposit_button.label = self.bot.translator.t(_p( + 'ui:room_status|button:deposit|label', + "Deposit" + )) + + @button(label='EDIT_PLACEHOLDER', style=ButtonStyle.blurple) + async def edit_button(self, press: discord.Interaction, pressed: Button): + if not await self.owner_ward(press): + return + + async def edit_button_refresh(self): + self.edit_button.label = self.bot.translator.t(_p( + 'ui:room_status|button:edit|label', + "Edit Room" + )) + self.edit_button.emoji = self.bot.config.emojis.config + + @button(label='TIMER_PLACEHOLDER', style=ButtonStyle.green) + async def timer_button(self, press: discord.Interaction, pressed: Button): + if not await self.owner_ward(press): + return + + timer = self.room.timer + if timer is not None: + await TimerEditor.open_editor(self.bot, press, timer, press.user) + else: + # Create a new owned timer + t = self.bot.translator.t + settings = [ + TimerOptions.FocusLength, + TimerOptions.BreakLength, + TimerOptions.InactivityThreshold, + TimerOptions.BaseName, + TimerOptions.ChannelFormat + ] + instances = [ + setting(self.room.data.channelid, setting._default) for setting in settings + ] + instances[3].data = self.room.data.name + inputs = [ + instance.input_field for instance in instances + ] + modal = TimerEditor( + *inputs, + title=t(_p( + 'ui:room_status|button:timer|modal:add_timer|title', + "Create Room Timer" + )) + ) + + @modal.submit_callback(timeout=10*60) + async def _create_timer_callback(submit: discord.Interaction): + try: + create_args = { + 'channelid': self.room.data.channelid, + 'guildid': self.room.data.guildid, + 'ownerid': self.room.data.ownerid, + 'notification_channelid': self.room.data.channelid, + 'manager_roleid': press.guild.default_role.id, + } + for instance, field in zip(instances, inputs): + try: + parsed = await instance.from_string(self.room.data.channelid, field.value) + except UserInputError as e: + _msg = f"`{instance.display_name}:` {e._msg}" + raise UserInputError(_msg, info=e.info, details=e.details) + create_args[parsed._column] = parsed._data + + # Parsing okay, start to create + await submit.response.defer(thinking=True) + + timer_cog = self.bot.get_cog('TimerCog') + timer = await timer_cog.create_timer(**create_args) + await timer.start() + + await submit.edit_original_response( + content=t(_p( + 'ui:room_status|button:timer|timer_created', + "Timer created successfully! Use `/pomodoro edit` to configure further." + )) + ) + await self.refresh() + except UserInputError: + raise + except Exception: + logger.exception( + "Unhandled exception occurred while creating timer for private room." + ) + await press.response.send_modal(modal) + + async def timer_button_refresh(self): + t = self.bot.translator.t + button = self.timer_button + if self.room.timer is not None: + button.label = t(_p( + 'ui:room_status|button:timer|label:edit_timer', + "Edit Timer" + )) + button.style = ButtonStyle.blurple + button.emoji = self.bot.config.emojis.config + else: + button.label = t(_p( + 'ui:room_status|button:timer|label:add_timer', + "Add Timer" + )) + button.style = ButtonStyle.green + button.emoji = self.bot.config.emojis.clock + + @button(emoji=conf.emojis.refresh, style=ButtonStyle.grey) + async def refresh_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + await self.refresh(thinking=press) + + async def refresh_button_refresh(self): + pass + + @button(emoji=conf.emojis.cancel, style=ButtonStyle.red) + async def close_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + await self.quit() + + async def close_button_refresh(self): + pass + + @select(cls=UserSelect, placeholder="INVITE_PLACEHOLDER", min_values=0, max_values=25) + async def invite_menu(self, selection: discord.Interaction, selected: UserSelect): + if not await self.owner_ward(selection): + return + t = self.bot.translator.t + + userids = set(user.id for user in selected.values) + userids.discard(self.room.data.ownerid) + userids.difference_update(self.room.members) + if not userids: + # No new members were given, quietly exit + await selection.response.defer() + return + + await selection.response.defer(thinking=True, ephemeral=True) + # Check cap + cap = self.room.lguild.config.get(RoomSettings.MemberLimit.setting_id).value + if len(self.room.members) + len(userids) >= cap: + await selection.edit_original_response( + embed=error_embed( + t(_p( + 'ui:room_status|menu:invite|error:too_many_members', + "Too many members! " + "You are inviting `{count}` new members to your room, " + "but you already have `{current}` members! " + "The member cap is `{cap}`." + )).format( + count=len(userids), + current=len(self.room.members) + 1, + cap=cap + ) + ) + ) + return + + # Add the members + await self.room.add_new_members(userids) + + await self.refresh(thinking=selection) + + async def invite_menu_refresh(self): + t = self.bot.translator.t + menu = self.invite_menu + cap = self.room.lguild.config.get('rooms_slots').value + if len(self.room.members) >= cap: + menu.disabled = True + menu.placeholder = t(_p( + 'ui:room_status|menu:invite_menu|placeholder:capped', + "Room member cap reached!" + )) + else: + menu.disabled = False + menu.placeholder = t(_p( + 'ui:room_status|menu:invite_menu|placeholder:notcapped', + "Add Members" + )) + + @select(cls=UserSelect, placeholder='KICK_MENU_PLACEHOLDER') + async def kick_menu(self, selection: discord.Interaction, selected: UserSelect): + if not await self.owner_ward(selection): + return + + userids = set(user.id for user in selected.values) + userids.intersection_update(self.room.members) + if not userids: + # No selected users are actually members of the room + await selection.response.defer() + return + + await selection.response.defer(thinking=True, ephemeral=True) + + await self.room.rm_members(userids) + await self.refresh(thinking=selection) + + async def kick_menu_refresh(self): + self.kick_menu.placeholder = self.bot.translator.t(_p( + 'ui:room_status|menu:kick_menu|placeholder', + "Remove Members" + )) + + # ----- UI Flow ----- + async def make_message(self) -> MessageArgs: + t = self.bot.translator.t + title = t(_p( + 'ui:room_status|embed|title', + "Room Control Panel" + )) + embed = discord.Embed( + title=title, + colour=discord.Colour.orange(), + ) + + embed.add_field( + name=t(_p('ui:room_status|embed|field:channel|name', "Channel")), + value=self.room.channel.mention + ) + + embed.add_field( + name=t(_p('ui:room_status|embed|field:owner|name', "Owner")), + value=f"<@{self.room.data.ownerid}>" + ) + + embed.add_field( + name=t(_p('ui:room_status|embed|field:created|name', "Created At")), + value=f"" + ) + + balance = self.room.data.coin_balance + rent = self.room.rent + next_tick = f"" + if self.room.expiring: + bank_value = t(_p( + 'ui:room_status|embed|field:bank|value:expiring', + "**Warning:** Insufficient room balance to pay next rent ({coin} **{rent}**).\n" + "The room will expire {expiry}.\nUse `/room deposit` to increase balance." + )).format( + coin=conf.emojis.coin, + amount=balance, + rent=rent, + expiry=next_tick + ) + else: + bank_value = t(_p( + 'ui:room_status|embed|field:bank|value:notexpiring', + "Next rent due {time} (- {coin}**{rent}**)" + )).format( + coin=conf.emojis.coin, + amount=balance, + rent=rent, + time=next_tick + ) + + embed.add_field( + name=t(_p('ui:room_status|embed|field:bank|name', "Room Balance: {coin}**{amount}**")).format( + coin=self.bot.config.emojis.coin, + amount=balance + ), + value=bank_value, + inline=False + ) + + member_cap = self.room.lguild.config.get('rooms_slots').value + embed.add_field( + name=t(_p( + 'ui:room_status|embed|field:members|name', + "Members ({count}/{cap})" + )).format(count=len(self.room.members) + 1, cap=member_cap), + value=', '.join(f"<@{userid}>" for userid in (self.room.data.ownerid, *self.room.members)) + ) + + return MessageArgs(embed=embed) + + async def refresh_layout(self): + if self._callerid == self.room.data.ownerid: + # If the owner called, show full config UI + await asyncio.gather( + self.desposit_button_refresh(), + # self.edit_button_refresh(), + self.refresh_button_refresh(), + self.close_button_refresh(), + self.timer_button_refresh(), + self.invite_menu_refresh(), + self.kick_menu_refresh() + ) + self.set_layout( + (self.desposit_button, self.timer_button, self.refresh_button, self.close_button), + (self.invite_menu, ), + (self.kick_menu, ) + ) + else: + # Just show deposit button + await asyncio.gather( + self.desposit_button_refresh(), + self.refresh_button_refresh(), + self.close_button_refresh(), + ) + self.set_layout( + (self.desposit_button, self.refresh_button, self.close_button), + ) + + async def reload(self): + """ + No need to reload-data, as we use the room as source of truth. + """ + ... diff --git a/src/modules/rooms/settings.py b/src/modules/rooms/settings.py new file mode 100644 index 00000000..c42db9a4 --- /dev/null +++ b/src/modules/rooms/settings.py @@ -0,0 +1,156 @@ +from settings import ModelData +from settings.groups import SettingGroup +from settings.setting_types import ChannelSetting, IntegerSetting, BoolSetting + +from meta import conf +from core.data import CoreData +from babel.translator import ctx_translator + +from . import babel + +_p = babel._p + + +class RoomSettings(SettingGroup): + class Category(ModelData, ChannelSetting): + setting_id = 'rooms_category' + _event = 'guildset_rooms_category' + + _display_name = _p( + 'guildset:room_category', "rooms_category" + ) + _desc = _p( + 'guildset:rooms_category|desc', + "Category in which to create private voice channels." + ) + _long_desc = _p( + 'guildset:room_category|long_desc', + "When a member uses `/room rent` to rent a new private room, " + "a private voice channel will be created under this category, " + "manageable by the member. " + "I must have permission to create new channels in this category, " + "as well as to manage permissions." + ) + + _model = CoreData.Guild + _column = CoreData.Guild.renting_category.name + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value is None: + # Shut down renting system + resp = t(_p( + 'guildset:rooms_category|set_response:unset', + "The private room category has been unset. Existing private rooms will not be affected. " + "Delete the channels manually to remove the private rooms." + )) + else: + resp = t(_p( + 'guildset:rooms_category|set_response:set', + "Private room category has been set to {channel}. Existing private rooms will be moved." + )).format(channel=self.value.mention) + return resp + + class Rent(ModelData, IntegerSetting): + setting_id = 'rooms_price' + _event = 'guildset_rooms_price' + + _display_name = _p( + 'guildset:rooms_price', "room_rent" + ) + _desc = _p( + 'guildset:rooms_rent|desc', + "Daily rent price for a private room." + ) + _long_desc = _p( + 'guildset:rooms_rent|long_desc', + "Members will be charged this many LionCoins for each day they rent a private room." + ) + _default = 1000 + + _model = CoreData.Guild + _column = CoreData.Guild.renting_price.name + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + resp = t(_p( + 'guildset:rooms_price|set_response', + "Private rooms will now cost {coin}**{amount}}** per 24 hours." + )).format( + coin=conf.emojis.coin, + amount=self.value + ) + return resp + + class MemberLimit(ModelData, IntegerSetting): + setting_id = 'rooms_slots' + _event = 'guildset_rooms_slots' + + _display_name = _p('guildset:rooms_slots', "room_member_cap") + _desc = _p( + 'guildset:rooms_slots|desc', + "Maximum number of members in each private room." + ) + _long_desc = _p( + 'guildset:rooms_slots|long_desc', + "Private room owners may invite other members to their private room via the UI, " + "or through the `/room invite` command. " + "This setting limits the maximum number of members a private room may hold." + ) + _default = 25 + + _model = CoreData.Guild + _column = CoreData.Guild.renting_cap.name + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + resp = t(_p( + 'guildset:rooms_slots|set_response', + "Private rooms are now capped to **{amount}** members." + )).format(amount=self.value) + return resp + + class Visible(ModelData, BoolSetting): + setting_id = 'rooms_visible' + _event = 'guildset_rooms_visible' + + _display_name = _p('guildset:rooms_visible', "room_visibility") + _desc = _p( + 'guildset:rooms_visible|desc', + "Whether private rented rooms are visible to non-members." + ) + _long_desc = _p( + 'guildset:rooms_visible|long_desc', + "If enabled, new private rooms will be created with the `VIEW_CHANNEL` permission " + "enabled for the `@everyone` role." + ) + _default = False + + _model = CoreData.Guild + _column = CoreData.Guild.renting_visible.name + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + if self.value: + resp = t(_p( + 'guildset:rooms_visible|set_response:enabled', + "Private rooms will now be visible to everyone." + )) + else: + resp = t(_p( + 'guildset:rooms_visible|set_response:disabled', + "Private rooms will now only be visible to their members (and admins)." + )) + return resp + + model_settings = ( + Category, + Rent, + MemberLimit, + Visible, + ) diff --git a/src/modules/rooms/settingui.py b/src/modules/rooms/settingui.py new file mode 100644 index 00000000..9ea516f0 --- /dev/null +++ b/src/modules/rooms/settingui.py @@ -0,0 +1,101 @@ +import asyncio + +import discord +from discord.ui.button import button, Button, ButtonStyle +from discord.ui.select import select, ChannelSelect + +from meta import LionBot + +from utils.ui import ConfigUI, DashboardSection +from utils.lib import MessageArgs + +from .settings import RoomSettings +from . import babel + +_p = babel._p + + +class RoomSettingUI(ConfigUI): + setting_classes = RoomSettings.model_settings + + def __init__(self, bot: LionBot, guildid: int, channelid: int, **kwargs): + self.settings = bot.get_cog('RoomCog').settings + super().__init__(bot, guildid, channelid, **kwargs) + + # ----- UI Components ----- + @select(cls=ChannelSelect, channel_types=[discord.ChannelType.category], + min_values=0, max_values=1, + placeholder='CATEGORY_PLACEHOLDER') + async def category_menu(self, selection: discord.Interaction, selected: ChannelSelect): + await selection.response.defer() + setting = self.instances[0] + setting.value = selected.values[0] if selected.values else None + await setting.write() + + async def category_menu_refresh(self): + self.category_menu.placeholder = self.bot.translator.t(_p( + 'ui:room_config|menu:category|placeholder', + "Select Private Room Category" + )) + + @button(label="VISIBLE_BUTTON_PLACEHOLDER", style=ButtonStyle.grey) + async def visible_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + setting = next(inst for inst in self.instances if inst.setting_id == RoomSettings.Visible.setting_id) + setting.value = not setting.value + await setting.write() + + async def visible_button_refresh(self): + button = self.visible_button + button.label = self.bot.translator.t(_p( + 'ui:room_config|button:visible|label', + "Toggle Room Visibility" + )) + setting = next(inst for inst in self.instances if inst.setting_id == RoomSettings.Visible.setting_id) + button.style = ButtonStyle.green if setting.value else ButtonStyle.grey + + # ----- UI Flow ----- + async def make_message(self) -> MessageArgs: + t = self.bot.translator.t + title = t(_p( + 'ui:rooms_config|embed|title', + "Private Room System Configuration Panel" + )) + embed = discord.Embed( + colour=discord.Colour.orange(), + title=title + ) + for setting in self.instances: + embed.add_field(**setting.embed_field, inline=False) + + args = MessageArgs(embed=embed) + return args + + async def reload(self): + lguild = await self.bot.core.lions.fetch_guild(self.guildid) + self.instances = tuple( + lguild.config.get(setting.setting_id) + for setting in self.settings.model_settings + ) + + async def refresh_components(self): + await asyncio.gather( + self.category_menu_refresh(), + self.visible_button_refresh(), + self.edit_button_refresh(), + self.close_button_refresh(), + self.reset_button_refresh(), + ) + self.set_layout( + (self.category_menu,), + (self.visible_button, self.edit_button, self.reset_button, self.close_button) + ) + + +class RoomDashboard(DashboardSection): + section_name = _p( + 'dash:rooms|title', + "Private Room Configuration" + ) + configui = RoomSettingUI + setting_classes = RoomSettingUI.setting_classes diff --git a/src/utils/ui/config.py b/src/utils/ui/config.py index 76a7bbbf..ae2aef1e 100644 --- a/src/utils/ui/config.py +++ b/src/utils/ui/config.py @@ -27,6 +27,7 @@ class ConfigEditor(FastModal): class ConfigUI(LeoUI): # TODO: Migrate to a subclass of MessageUI + # TODO: Move instances to a {setting_id: instance} map for easy retrieval _listening = {} setting_classes = [] diff --git a/src/utils/ui/leo.py b/src/utils/ui/leo.py index b24c1ad0..ca9c9b30 100644 --- a/src/utils/ui/leo.py +++ b/src/utils/ui/leo.py @@ -337,6 +337,15 @@ class MessageUI(LeoUI): self._original = interaction await interaction.response.send_message(**args.send_args, **kwargs, view=self) + async def send(self, channel: discord.abc.Messageable, **kwargs): + """ + Alternative to draw() which uses a discord.abc.Messageable. + """ + await self.reload() + await self.refresh_layout() + args = await self.make_message() + self._message = await channel.send(**args.send_args, view=self) + async def redraw(self, thinking: Optional[discord.Interaction] = None): """ Update the output message for this UI.