rewrite: New private Room system.
This commit is contained in:
@@ -47,6 +47,7 @@ loading = <:cyclebigger:975880828611600404>
|
||||
tick = :✅:
|
||||
clock = :⏱️:
|
||||
warning = :⚠️:
|
||||
config = :⚙️:
|
||||
|
||||
coin = <:coin:975880967485022239>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
-- }}}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ active = [
|
||||
'.tasklist',
|
||||
'.statistics',
|
||||
'.pomodoro',
|
||||
'.rooms',
|
||||
'.test',
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
from .status import TimerStatusUI
|
||||
from .edit import TimerEditor
|
||||
from .config import TimerOptionsUI
|
||||
|
||||
@@ -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(
|
||||
|
||||
10
src/modules/rooms/__init__.py
Normal file
10
src/modules/rooms/__init__.py
Normal 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
941
src/modules/rooms/cog.py
Normal 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
48
src/modules/rooms/data.py
Normal 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
41
src/modules/rooms/lib.py
Normal 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
278
src/modules/rooms/room.py
Normal 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
461
src/modules/rooms/roomui.py
Normal 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.
|
||||
"""
|
||||
...
|
||||
156
src/modules/rooms/settings.py
Normal file
156
src/modules/rooms/settings.py
Normal 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,
|
||||
)
|
||||
101
src/modules/rooms/settingui.py
Normal file
101
src/modules/rooms/settingui.py
Normal 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
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user