diff --git a/bot/modules/__init__.py b/bot/modules/__init__.py index 2550fff9..ad94ecff 100644 --- a/bot/modules/__init__.py +++ b/bot/modules/__init__.py @@ -7,4 +7,5 @@ from .user_config import * from .workout import * from .todo import * from .reminders import * +from .renting import * # from .moderation import * diff --git a/bot/modules/channels/__init__.py b/bot/modules/channels/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/modules/renting/__init__.py b/bot/modules/renting/__init__.py new file mode 100644 index 00000000..08eba06b --- /dev/null +++ b/bot/modules/renting/__init__.py @@ -0,0 +1,5 @@ +from .module import module + +from . import commands +from . import rooms +from . import admin diff --git a/bot/modules/renting/admin.py b/bot/modules/renting/admin.py new file mode 100644 index 00000000..2427e136 --- /dev/null +++ b/bot/modules/renting/admin.py @@ -0,0 +1,76 @@ +import discord + +from settings import GuildSettings, GuildSetting +import settings + + +@GuildSettings.attach_setting +class rent_category(settings.Channel, GuildSetting): + category = "Rented Rooms" + + attr_name = "rent_category" + _data_column = "renting_category" + + display_name = "rent_category" + desc = "Category in which members can rent their own study rooms." + + _default = None + + long_desc = ( + "Members can use the `rent` command to " + "buy the use of a new private voice channel in this category for `24h`." + ) + _accepts = "A category channel." + + _chan_type = discord.ChannelType.category + + @property + def success_response(self): + if self.value: + return "Members may now rent private voice channels under **{}**.".format(self.value.name) + else: + return "Members may no longer rent private voice channels." + + +@GuildSettings.attach_setting +class rent_member_limit(settings.Integer, GuildSetting): + category = "Rented Rooms" + + attr_name = "rent_member_limit" + _data_column = "renting_cap" + + display_name = "rent_member_limit" + desc = "Maximum number of people that can be added to a rented room." + + _default = 10 + + long_desc = ( + "Maximum number of people a member can add to a rented private voice channel." + ) + _accepts = "An integer number of members." + + @property + def success_response(self): + return "Members will now be able to add at most `{}` people to their channel.".format(self.value) + + +@GuildSettings.attach_setting +class rent_room_price(settings.Integer, GuildSetting): + category = "Rented Rooms" + + attr_name = "rent_room_price" + _data_column = "renting_price" + + display_name = "rent_price" + desc = "Price of a privated voice channel." + + _default = 1000 + + long_desc = ( + "How much it costs for a member to rent a private voice channel." + ) + _accepts = "An integer number of coins." + + @property + def success_response(self): + return "Private voice channels now cost `{}` coins.".format(self.value) diff --git a/bot/modules/renting/commands.py b/bot/modules/renting/commands.py new file mode 100644 index 00000000..9e0c4567 --- /dev/null +++ b/bot/modules/renting/commands.py @@ -0,0 +1,202 @@ +import discord +from cmdClient.checks import in_guild + +from .module import module +from .rooms import Room + + +@module.cmd( + name="rent", + desc="Rent a private study room!", + group="Productivity", + aliases=('add',) +) +@in_guild() +async def cmd_rent(ctx): + """ + Usage``: + {prefix}rent + {prefix}rent @user1 @user2 @user3 ... + {prefix}rent add @user1 @user2 @user3 ... + {prefix}rent remove @user1 @user2 @user3 ... + Description: + Rent a private voice channel for 24 hours,\ + and invite up to `{ctx.guild_settings.rent_member_limit.value}` mentioned users. + Use `{prefix}rent add` and `{prefix}rent remove` to give/revoke access to your room. + + *Renting a private channel costs `{ctx.guild_settings.rent_room_price.value} LC`.* + """ + # TODO: More gracefully handle unexpected channel deletion + + # Check if the category is set up + if not ctx.guild_settings.rent_category.value: + return await ctx.error_reply( + "The private study channel category has not been set up! Please come back later." + ) + + # Fetch the members' room, if it exists + room = Room.fetch(ctx.guild.id, ctx.author.id) + + if room: + # Show room status, or add/remove remebers + lower = ctx.args.lower() + if ctx.msg.mentions and lower and (lower.startswith('-') or lower.startswith('remove')): + # Remove the mentioned members + + # Extract members to remove + current_memberids = set(room.memberids) + to_remove = ( + member for member in ctx.msg.mentions + if member.id in current_memberids + ) + to_remove = list(set(to_remove)) # Remove duplicates + + # Check if there are no members to remove + if not to_remove: + return await ctx.error_reply( + "None of these members have access to your study room!" + ) + + # Finally, remove the members from the room and ack + await room.remove_members(*to_remove) + + await ctx.embed_reply( + "The following members have been removed from your room:\n{}".format( + ', '.join(member.mention for member in to_remove) + ) + ) + elif lower == 'delete': + if await ctx.ask("Are you sure you want to delete your study room? No refunds given!"): + # TODO: Better deletion log + await room._execute() + await ctx.embed_reply("Private study room deleted.") + elif ctx.msg.mentions: + # Add the mentioned members + + # Extract members to add + current_memberids = set(room.memberids) + to_add = ( + member for member in ctx.msg.mentions + if member.id not in current_memberids and member.id != ctx.author + ) + to_add = list(set(to_add)) # Remove duplicates + + # Check if there are no members to add + if not to_add: + return await ctx.error_reply( + "All of these members already have access to your room!" + ) + + # Check that they didn't provide too many members + limit = ctx.guild_settings.rent_member_limit.value + if len(to_add) + len(current_memberids) > limit: + return await ctx.error_reply( + "Too many members! You can invite at most `{}` members to your room.".format( + limit + ) + ) + + # Finally, add the members to the room and ack + await room.add_members(*to_add) + + await ctx.embed_reply( + "The following members have been given access to your room:\n{}".format( + ', '.join(member.mention for member in to_add) + ) + ) + else: + # Show room status with hints for adding and removing members + # Ack command + embed = discord.Embed( + colour=discord.Colour.orange() + ).set_author( + name="{}'s private room".format(ctx.author.display_name), + icon_url=ctx.author.avatar_url + ).add_field( + name="Channel", + value=room.channel.mention + ).add_field( + name="Expires", + value="".format(room.timestamp) + ).add_field( + name="Members", + value=', '.join('<@{}>'.format(memberid) for memberid in room.memberids) or "None", + inline=False + ).set_footer( + text=( + "Use '{prefix}rent add @mention' and '{prefix}rent remove @mention'\n" + "to add and remove members.".format(prefix=ctx.best_prefix) + ), + icon_url="https://projects.iamcal.com/emoji-data/img-apple-64/1f4a1.png" + ) + await ctx.reply(embed=embed) + else: + if ctx.args: + # Rent a new room + + # Check that they provided at least one member + if not ctx.msg.mentions: + return await ctx.error_reply( + "Please mention at least one user to add to your new room." + ) + + to_add = ( + member for member in ctx.msg.mentions if member != ctx.author + ) + to_add = list(set(to_add)) + + # Check that they didn't provide too many members + limit = ctx.guild_settings.rent_member_limit.value + if len(ctx.msg.mentions) > limit: + return await ctx.error_reply( + "Too many members! You can invite at most `{}` members to your room.".format( + limit + ) + ) + + # Check that they have enough money for this + cost = ctx.guild_settings.rent_room_price.value + if ctx.alion.coins < cost: + return await ctx.error_reply( + "Sorry, a private room costs `{}` coins, but you only have `{}`.".format( + cost, + ctx.alion.coins + ) + ) + + # Create the room + room = await Room.create(ctx.author, to_add) + + # Deduct cost + ctx.alion.addCoins(-cost) + + # Ack command + embed = discord.Embed( + colour=discord.Colour.orange(), + title="Private study room rented!", + ).add_field( + name="Channel", + value=room.channel.mention + ).add_field( + name="Expires", + value="".format(room.timestamp) + ).add_field( + name="Members", + value=', '.join(member.mention for member in to_add), + inline=False + ).set_footer( + text="See your room status at any time with {prefix}rent".format(prefix=ctx.best_prefix), + icon_url="https://projects.iamcal.com/emoji-data/img-apple-64/1f4a1.png" + ) + await ctx.reply(embed=embed) + else: + # Suggest they get a room + await ctx.embed_reply( + "Rent a private study room for 24 hours with up to `{}` " + "friends by mentioning them with this command! (Rooms cost `{}` LionCoins.)\n" + "`{}rent @user1 @user2 ...`".format( + ctx.guild_settings.rent_member_limit.value, + ctx.best_prefix, + ctx.guild_settings.rent_room_price.value, + ) + ) diff --git a/bot/modules/renting/data.py b/bot/modules/renting/data.py new file mode 100644 index 00000000..e0b3dfc6 --- /dev/null +++ b/bot/modules/renting/data.py @@ -0,0 +1,11 @@ +from data import RowTable, Table + + +rented = RowTable( + 'rented', + ('channelid', 'guildid', 'ownerid', 'expires_at', 'created_at'), + 'channelid' +) + + +rented_members = Table('rented_members') diff --git a/bot/modules/renting/module.py b/bot/modules/renting/module.py new file mode 100644 index 00000000..f4ab6a51 --- /dev/null +++ b/bot/modules/renting/module.py @@ -0,0 +1,4 @@ +from LionModule import LionModule + + +module = LionModule("Rented Rooms") diff --git a/bot/modules/renting/rooms.py b/bot/modules/renting/rooms.py new file mode 100644 index 00000000..36bc39f0 --- /dev/null +++ b/bot/modules/renting/rooms.py @@ -0,0 +1,283 @@ +import discord +import asyncio +import datetime + +from cmdClient.lib import SafeCancellation + +from meta import client +from settings import GuildSettings + +from .data import rented, rented_members +from .module import module + + +class Room: + __slots__ = ('key', 'map_key', '_task') + + everyone_overwrite = discord.PermissionOverwrite( + view_channel=False + ) + owner_overwrite = discord.PermissionOverwrite( + view_channel=True, + connect=True, + priority_speaker=True + ) + member_overwrite = discord.PermissionOverwrite( + view_channel=True, + connect=True, + ) + + _table = rented + + _rooms = {} # map (guildid, userid) -> Room + + def __init__(self, channelid): + self.key = channelid + self.map_key = (self.data.guildid, self.data.ownerid) + + self._task = None + + @classmethod + async def create(cls, owner: discord.Member, initial_members): + ownerid = owner.id + guild = owner.guild + guildid = guild.id + guild_settings = GuildSettings(guildid) + + category = guild_settings.rent_category.value + if not category: + # This should never happen + return SafeCancellation("Rent category not set up!") + + # First create the channel, with the needed overrides + overwrites = { + guild.default_role: cls.everyone_overwrite, + owner: cls.owner_overwrite + } + overwrites.update( + {member: cls.member_overwrite for member in initial_members} + ) + try: + channel = await guild.create_voice_channel( + name="{}'s private channel".format(owner.name), + overwrites=overwrites, + category=category + ) + channelid = channel.id + except discord.HTTPException: + guild_settings.event_log.log( + description="Failed to create a private room for {}!".format(owner.mention), + colour=discord.Colour.red() + ) + raise SafeCancellation("Couldn't create the private channel! Please try again later.") + + # Add the new room to data + cls._table.create_row( + channelid=channelid, + guildid=guildid, + ownerid=ownerid + ) + + # Add the members to data, if any + if initial_members: + rented_members.insert_many( + *((channelid, member.id) for member in initial_members) + ) + + # Log the creation + guild_settings.event_log.log( + title="New private study room!", + description="Created a private study room for {} with:\n{}".format( + owner.mention, + ', '.join(member.mention for member in initial_members) + ) + ) + + # Create the room, schedule its expiry, and return + room = cls(channelid) + room.schedule() + return room + + @classmethod + def fetch(cls, guildid, userid): + """ + Fetch a Room owned by a given member. + """ + return cls._rooms.get((guildid, userid), None) + + @property + def data(self): + return self._table.fetch(self.key) + + @property + def owner(self): + """ + The Member owning the room, if we can find them + """ + guild = client.get_guild(self.data.guildid) + if guild: + return guild.get_member(self.data.ownerid) + + @property + def channel(self): + """ + The Channel corresponding to this rented room. + """ + guild = client.get_guild(self.data.guildid) + if guild: + return guild.get_channel(self.key) + + @property + def memberids(self): + """ + The list of memberids in the channel. + """ + return [row['userid'] for row in rented_members.select_where(channelid=self.key)] + + @property + def timestamp(self): + """ + True unix timestamp for the room expiry time. + """ + return int(self.data.expires_at.replace(tzinfo=datetime.timezone.utc).timestamp()) + + def delete(self): + """ + Delete the room in an idempotent way. + """ + if self._task and not self._task.done(): + self._task.cancel() + self._rooms.pop(self.map_key, None) + self._table.delete_where(channelid=self.key) + + def schedule(self): + """ + Schedule this room to be expired. + """ + asyncio.create_task(self._schedule()) + self._rooms[self.map_key] = self + + async def _schedule(self): + """ + Expire the room after a sleep period. + """ + # Calculate time left + remaining = (self.data.expires_at - datetime.datetime.utcnow()).total_seconds() + + # Create the waiting task and wait for it, accepting cancellation + self._task = asyncio.create_task(asyncio.sleep(remaining)) + try: + await self._task + except asyncio.CancelledError: + return + await self._execute() + + async def _execute(self): + """ + Expire the room. + """ + owner = self.owner + guild_settings = GuildSettings(owner.guild.id) + + if self.channel: + # Delete the discord channel + try: + await self.channel.delete() + except discord.HTTPException: + pass + + # Delete the room from data (cascades to member deletion) + self.delete() + + guild_settings.event_log.log( + title="Private study room expired!", + description="{}'s private study room expired.".format(owner.mention) + ) + + async def add_members(self, *members): + guild_settings = GuildSettings(self.data.guildid) + + # Update overwrites + new_overwrites = {member: self.member_overwrite for member in members} + try: + await self.channel.edit(overwrites=new_overwrites) + except discord.HTTPException: + guild_settings.event_log.log( + title="Failed to update study room permissions!", + description="An error occured while adding the following users to the private room {}.\n{}".format( + self.channel.mention, + ', '.join(member.mention for member in members) + ), + colour=discord.Colour.red() + ) + raise SafeCancellation("Sorry, something went wrong while adding the members!") + + # Update data + rented_members.insert_many( + *((self.key, member.id) for member in members) + ) + + # Log + guild_settings.event_log.log( + title="New members added to private study room", + description="The following were added to {}.\n{}".format( + self.channel.mention, + ', '.join(member.mention for member in members) + ) + ) + + async def remove_members(self, *members): + guild_settings = GuildSettings(self.data.guildid) + + if self.channel: + # Update overwrites + try: + await asyncio.gather( + *(self.channel.set_permissions( + member, + overwrite=None, + reason="Removing members from private channel.") for member in members) + ) + except discord.HTTPException: + guild_settings.event_log.log( + title="Failed to update study room permissions!", + description=("An error occured while removing the " + "following members from the private room {}.\n{}").format( + self.channel.mention, + ', '.join(member.mention for member in members) + ), + colour=discord.Colour.red() + ) + raise SafeCancellation("Sorry, something went wrong while removing those members!") + + # Disconnect members if possible: + to_disconnect = set(self.channel.members).intersection(members) + try: + await asyncio.gather( + *(member.edit(voice_channel=None) for member in to_disconnect) + ) + except discord.HTTPException: + pass + + # Update data + rented_members.delete_where(channelid=self.key, userid=[member.id for member in members]) + + # Log + guild_settings.event_log.log( + title="Members removed from a private study room", + description="The following were removed from {}.\n{}".format( + self.channel.mention if self.channel else "`{}`".format(self.key), + ', '.join(member.mention for member in members) + ) + ) + + +@module.launch_task +async def load_rented_rooms(client): + rows = rented.fetch_rows_where() + for row in rows: + Room(row.channelid).schedule() + client.log( + "Loaded {} private study channels.".format(len(rows)), + context="LOAD_RENTED_ROOMS" + ) diff --git a/data/schema.sql b/data/schema.sql index 5d69ada5..12addb55 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -45,7 +45,12 @@ CREATE TABLE guild_config( task_reward INTEGER, task_reward_limit INTEGER, study_hourly_reward INTEGER, - study_hourly_live_bonus INTEGER + study_hourly_live_bonus INTEGER, + renting_price INTEGER, + renting_category BIGINT, + renting_cap INTEGER, + renting_role BIGINT, + renting_sync_perms BOOLEAN ); CREATE TABLE unranked_roles( @@ -296,4 +301,21 @@ CREATE VIEW new_study_badges AS ORDER BY guildid; -- }}} +-- Rented Room data {{{ +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); +-- }}} -- vim: set fdm=marker: