rewrite: New private Room system.

This commit is contained in:
2023-05-23 17:49:37 +03:00
parent 4aa2587c45
commit f0dd540876
19 changed files with 2114 additions and 8 deletions

View File

@@ -47,6 +47,7 @@ loading = <:cyclebigger:975880828611600404>
tick = :✅:
clock = :⏱️:
warning = :⚠️:
config = :⚙️:
coin = <:coin:975880967485022239>

View File

@@ -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;
-- }}}

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ active = [
'.tasklist',
'.statistics',
'.pomodoro',
'.rooms',
'.test',
]

View File

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

View File

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

View File

@@ -1 +1,3 @@
from .status import TimerStatusUI
from .edit import TimerEditor
from .config import TimerOptionsUI

View File

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

View File

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

941
src/modules/rooms/cog.py Normal file
View File

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

48
src/modules/rooms/data.py Normal file
View File

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

41
src/modules/rooms/lib.py Normal file
View File

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

278
src/modules/rooms/room.py Normal file
View File

@@ -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 <cid: {self.data.channelid}> for reason '{reason}': {self.data!r}"
)
await self.data.update(deleted_at=utc_now())

461
src/modules/rooms/roomui.py Normal file
View File

@@ -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"<t:{int(self.room.data.created_at.timestamp())}>"
)
balance = self.room.data.coin_balance
rent = self.room.rent
next_tick = f"<t:{int(self.room.next_tick.timestamp())}:R>"
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.
"""
...

View File

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

View File

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

View File

@@ -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 = []

View File

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