(Renting): Created the Renting module and system.

This commit is contained in:
2021-09-16 12:23:06 +03:00
parent 0e63c2da7a
commit f236ca2e97
9 changed files with 605 additions and 1 deletions

View File

@@ -7,4 +7,5 @@ from .user_config import *
from .workout import *
from .todo import *
from .reminders import *
from .renting import *
# from .moderation import *

View File

@@ -0,0 +1,5 @@
from .module import module
from . import commands
from . import rooms
from . import admin

View File

@@ -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)

View File

@@ -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="<t:{}:R>".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="<t:{}:R>".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,
)
)

View File

@@ -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')

View File

@@ -0,0 +1,4 @@
from LionModule import LionModule
module = LionModule("Rented Rooms")

View File

@@ -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"
)

View File

@@ -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: