rewrite: New Scheduled Session System.
This commit is contained in:
@@ -154,7 +154,10 @@ CREATE TYPE CoinTransactionType AS ENUM(
|
|||||||
'VOICE_SESSION',
|
'VOICE_SESSION',
|
||||||
'TEXT_SESSION',
|
'TEXT_SESSION',
|
||||||
'ADMIN',
|
'ADMIN',
|
||||||
'TASKS'
|
'TASKS',
|
||||||
|
'SCHEDULE_BOOK',
|
||||||
|
'SCHEDULE_REWARD',
|
||||||
|
'OTHER'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -795,6 +798,136 @@ CREATE TABLE channel_webhooks(
|
|||||||
|
|
||||||
-- }}}
|
-- }}}
|
||||||
|
|
||||||
|
-- Scheduled Sessions {{{
|
||||||
|
/* Old Schema
|
||||||
|
CREATE TABLE accountability_slots(
|
||||||
|
slotid SERIAL PRIMARY KEY,
|
||||||
|
guildid BIGINT NOT NULL REFERENCES guild_config(guildid),
|
||||||
|
channelid BIGINT,
|
||||||
|
start_at TIMESTAMPTZ (0) NOT NULL,
|
||||||
|
messageid BIGINT,
|
||||||
|
closed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX slot_channels ON accountability_slots(channelid);
|
||||||
|
CREATE UNIQUE INDEX slot_guilds ON accountability_slots(guildid, start_at);
|
||||||
|
CREATE INDEX slot_times ON accountability_slots(start_at);
|
||||||
|
|
||||||
|
CREATE TABLE accountability_members(
|
||||||
|
slotid INTEGER NOT NULL REFERENCES accountability_slots(slotid) ON DELETE CASCADE,
|
||||||
|
userid BIGINT NOT NULL,
|
||||||
|
paid INTEGER NOT NULL,
|
||||||
|
duration INTEGER DEFAULT 0,
|
||||||
|
last_joined_at TIMESTAMPTZ,
|
||||||
|
PRIMARY KEY (slotid, userid)
|
||||||
|
);
|
||||||
|
CREATE INDEX slot_members ON accountability_members(userid);
|
||||||
|
CREATE INDEX slot_members_slotid ON accountability_members(slotid);
|
||||||
|
|
||||||
|
CREATE VIEW accountability_member_info AS
|
||||||
|
SELECT
|
||||||
|
*
|
||||||
|
FROM accountability_members
|
||||||
|
JOIN accountability_slots USING (slotid);
|
||||||
|
|
||||||
|
CREATE VIEW accountability_open_slots AS
|
||||||
|
SELECT
|
||||||
|
*
|
||||||
|
FROM accountability_slots
|
||||||
|
WHERE closed_at IS NULL
|
||||||
|
ORDER BY start_at ASC;
|
||||||
|
*/
|
||||||
|
-- Create new schema
|
||||||
|
CREATE TABLE schedule_slots(
|
||||||
|
slotid INTEGER PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE schedule_guild_config(
|
||||||
|
guildid BIGINT PRIMARY KEY REFERENCES guild_config ON DELETE CASCADE,
|
||||||
|
schedule_cost INTEGER,
|
||||||
|
reward INTEGER,
|
||||||
|
bonus_reward INTEGER,
|
||||||
|
min_attendance INTEGER,
|
||||||
|
lobby_channel BIGINT,
|
||||||
|
room_channel BIGINT,
|
||||||
|
blacklist_after INTEGER,
|
||||||
|
blacklistrole BIGINT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE schedule_channels(
|
||||||
|
guildid BIGINT NOT NULL REFERENCES schedule_guild_config ON DELETE CASCADE,
|
||||||
|
channelid BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (guildid, channelid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE schedule_sessions(
|
||||||
|
guildid BIGINT NOT NULL REFERENCES schedule_guild_config ON DELETE CASCADE,
|
||||||
|
slotid INTEGER NOT NULL REFERENCES schedule_slots ON DELETE CASCADE,
|
||||||
|
opened_at TIMESTAMPTZ,
|
||||||
|
closed_at TIMESTAMPTZ,
|
||||||
|
messageid BIGINT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
PRIMARY KEY (guildid, slotid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE schedule_session_members(
|
||||||
|
guildid BIGINT NOT NULL,
|
||||||
|
userid BIGINT NOT NULL,
|
||||||
|
slotid INTEGER NOT NULL,
|
||||||
|
booked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
attended BOOLEAN NOT NULL DEFAULT False,
|
||||||
|
clock INTEGER NOT NULL DEFAULT 0,
|
||||||
|
book_transactionid INTEGER REFERENCES coin_transactions,
|
||||||
|
reward_transactionid INTEGER REFERENCES coin_transactions,
|
||||||
|
PRIMARY KEY (guildid, userid, slotid),
|
||||||
|
FOREIGN KEY (guildid, userid) REFERENCES members ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (guildid, slotid) REFERENCES schedule_sessions (guildid, slotid) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX schedule_session_members_users ON schedule_session_members(userid, slotid);
|
||||||
|
|
||||||
|
-- Migrate data
|
||||||
|
--- Create schedule_slots from accountability_slots
|
||||||
|
INSERT INTO schedule_slots (slotid)
|
||||||
|
SELECT EXTRACT(EPOCH FROM old_slots.start_time)
|
||||||
|
FROM (SELECT DISTINCT(start_at) AS start_time FROM accountability_slots) AS old_slots;
|
||||||
|
|
||||||
|
--- Create schedule_guild_config from guild_config
|
||||||
|
INSERT INTO schedule_guild_config (guildid, schedule_cost, reward, bonus_reward, lobby_channel)
|
||||||
|
SELECT guildid, accountability_price, accountability_reward, accountability_bonus, accountability_lobby
|
||||||
|
FROM guild_config
|
||||||
|
WHERE guildid IN (SELECT DISTINCT(guildid) FROM accountability_slots);
|
||||||
|
|
||||||
|
--- Update session rooms from accountability_slots
|
||||||
|
WITH open_slots AS (
|
||||||
|
SELECT guildid, MAX(channelid) AS channelid
|
||||||
|
FROM accountability_slots
|
||||||
|
WHERE closed_at IS NULL
|
||||||
|
GROUP BY guildid
|
||||||
|
)
|
||||||
|
UPDATE schedule_guild_config
|
||||||
|
SET room_channel = open_slots.channelid
|
||||||
|
FROM open_slots
|
||||||
|
WHERE schedule_guild_config.guildid = open_slots.guildid;
|
||||||
|
|
||||||
|
--- Create schedule_sessions from accountability_slots
|
||||||
|
INSERT INTO schedule_sessions (guildid, slotid, opened_at, closed_at)
|
||||||
|
SELECT guildid, new_slots.slotid, start_at, closed_at
|
||||||
|
FROM accountability_slots old_slots
|
||||||
|
LEFT JOIN schedule_slots new_slots
|
||||||
|
ON EXTRACT(EPOCH FROM old_slots.start_at) = new_slots.slotid;
|
||||||
|
|
||||||
|
--- Create schedule_session_members from accountability_members
|
||||||
|
INSERT INTO schedule_session_members (guildid, userid, slotid, booked_at, attended, clock)
|
||||||
|
SELECT old_slots.guildid, members.userid, new_slots.slotid, old_slots.start_at, (members.duration > 0), members.duration
|
||||||
|
FROM accountability_members members
|
||||||
|
LEFT JOIN accountability_slots old_slots ON members.slotid = old_slots.slotid
|
||||||
|
LEFT JOIN schedule_slots new_slots
|
||||||
|
ON EXTRACT(EPOCH FROM old_slots.start_at) = new_slots.slotid;
|
||||||
|
|
||||||
|
-- Drop old schema
|
||||||
|
-- }}}
|
||||||
|
|
||||||
INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration');
|
INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration');
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
97
data/migration/v12-13/schedule.sql
Normal file
97
data/migration/v12-13/schedule.sql
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
DROP TABLE IF EXISTS schedule_slots CASCADE;
|
||||||
|
DROP TABLE IF EXISTS schedule_guild_config CASCADE;
|
||||||
|
DROP TABLE IF EXISTS schedule_channels CASCADE;
|
||||||
|
DROP TABLE IF EXISTS schedule_sessions CASCADE;
|
||||||
|
DROP TABLE IF EXISTS schedule_session_members CASCADE;
|
||||||
|
|
||||||
|
-- Create new schema
|
||||||
|
|
||||||
|
CREATE TABLE schedule_slots(
|
||||||
|
slotid INTEGER PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE schedule_guild_config(
|
||||||
|
guildid BIGINT PRIMARY KEY REFERENCES guild_config ON DELETE CASCADE,
|
||||||
|
schedule_cost INTEGER,
|
||||||
|
reward INTEGER,
|
||||||
|
bonus_reward INTEGER,
|
||||||
|
min_attendance INTEGER,
|
||||||
|
lobby_channel BIGINT,
|
||||||
|
room_channel BIGINT,
|
||||||
|
blacklist_after INTEGER,
|
||||||
|
blacklist_role BIGINT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE schedule_channels(
|
||||||
|
guildid BIGINT NOT NULL REFERENCES schedule_guild_config ON DELETE CASCADE,
|
||||||
|
channelid BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (guildid, channelid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE schedule_sessions(
|
||||||
|
guildid BIGINT NOT NULL REFERENCES schedule_guild_config ON DELETE CASCADE,
|
||||||
|
slotid INTEGER NOT NULL REFERENCES schedule_slots ON DELETE CASCADE,
|
||||||
|
opened_at TIMESTAMPTZ,
|
||||||
|
closed_at TIMESTAMPTZ,
|
||||||
|
messageid BIGINT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
PRIMARY KEY (guildid, slotid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE schedule_session_members(
|
||||||
|
guildid BIGINT NOT NULL,
|
||||||
|
userid BIGINT NOT NULL,
|
||||||
|
slotid INTEGER NOT NULL,
|
||||||
|
booked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
attended BOOLEAN NOT NULL DEFAULT False,
|
||||||
|
clock INTEGER NOT NULL DEFAULT 0,
|
||||||
|
book_transactionid INTEGER REFERENCES coin_transactions,
|
||||||
|
reward_transactionid INTEGER REFERENCES coin_transactions,
|
||||||
|
PRIMARY KEY (guildid, userid, slotid),
|
||||||
|
FOREIGN KEY (guildid, userid) REFERENCES members ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (guildid, slotid) REFERENCES schedule_sessions (guildid, slotid) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX schedule_session_members_users ON schedule_session_members(userid, slotid);
|
||||||
|
|
||||||
|
-- Migrate data
|
||||||
|
--- Create schedule_slots from accountability_slots
|
||||||
|
INSERT INTO schedule_slots (slotid)
|
||||||
|
SELECT EXTRACT(EPOCH FROM old_slots.start_time)
|
||||||
|
FROM (SELECT DISTINCT(start_at) AS start_time FROM accountability_slots) AS old_slots;
|
||||||
|
|
||||||
|
--- Create schedule_guild_config from guild_config
|
||||||
|
INSERT INTO schedule_guild_config (guildid, schedule_cost, reward, bonus_reward, lobby_channel)
|
||||||
|
SELECT guildid, accountability_price, accountability_reward, accountability_bonus, accountability_lobby
|
||||||
|
FROM guild_config
|
||||||
|
WHERE guildid IN (SELECT DISTINCT(guildid) FROM accountability_slots);
|
||||||
|
|
||||||
|
--- Update session rooms from accountability_slots
|
||||||
|
WITH open_slots AS (
|
||||||
|
SELECT guildid, MAX(channelid) AS channelid
|
||||||
|
FROM accountability_slots
|
||||||
|
WHERE closed_at IS NULL
|
||||||
|
GROUP BY guildid
|
||||||
|
)
|
||||||
|
UPDATE schedule_guild_config
|
||||||
|
SET room_channel = open_slots.channelid
|
||||||
|
FROM open_slots
|
||||||
|
WHERE schedule_guild_config.guildid = open_slots.guildid;
|
||||||
|
|
||||||
|
--- Create schedule_sessions from accountability_slots
|
||||||
|
INSERT INTO schedule_sessions (guildid, slotid, opened_at, closed_at)
|
||||||
|
SELECT guildid, new_slots.slotid, start_at, closed_at
|
||||||
|
FROM accountability_slots old_slots
|
||||||
|
LEFT JOIN schedule_slots new_slots
|
||||||
|
ON EXTRACT(EPOCH FROM old_slots.start_at) = new_slots.slotid;
|
||||||
|
|
||||||
|
--- Create schedule_session_members from accountability_members
|
||||||
|
INSERT INTO schedule_session_members (guildid, userid, slotid, booked_at, attended, clock)
|
||||||
|
SELECT old_slots.guildid, members.userid, new_slots.slotid, old_slots.start_at, (members.duration > 0), members.duration
|
||||||
|
FROM accountability_members members
|
||||||
|
LEFT JOIN accountability_slots old_slots ON members.slotid = old_slots.slotid
|
||||||
|
LEFT JOIN schedule_slots new_slots
|
||||||
|
ON EXTRACT(EPOCH FROM old_slots.start_at) = new_slots.slotid;
|
||||||
|
|
||||||
|
-- Drop old schema
|
||||||
@@ -18,14 +18,14 @@ def loop_exception_handler(loop, context):
|
|||||||
print(context)
|
print(context)
|
||||||
task: asyncio.Task = context.get('task', None)
|
task: asyncio.Task = context.get('task', None)
|
||||||
if task is not None:
|
if task is not None:
|
||||||
addendum = f"<Task name='{task.name}' stack='{task.get_stack()}'>"
|
addendum = f"<Task name='{task.get_name()}' stack='{task.get_stack()}'>"
|
||||||
message = context.get('message', '')
|
message = context.get('message', '')
|
||||||
context['message'] = ' '.join((message, addendum))
|
context['message'] = ' '.join((message, addendum))
|
||||||
loop.default_exception_handler(context)
|
loop.default_exception_handler(context)
|
||||||
|
|
||||||
|
|
||||||
event_loop.set_exception_handler(loop_exception_handler)
|
event_loop.set_exception_handler(loop_exception_handler)
|
||||||
event_loop.set_debug(enabled=True)
|
# event_loop.set_debug(enabled=True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
from .cog import CoreCog
|
from .cog import CoreCog
|
||||||
from .config import ConfigCog
|
from .config import ConfigCog
|
||||||
|
|
||||||
|
from babel.translator import LocalBabel
|
||||||
|
|
||||||
|
|
||||||
|
babel = LocalBabel('lion-core')
|
||||||
|
|
||||||
|
|
||||||
async def setup(bot):
|
async def setup(bot):
|
||||||
await bot.add_cog(CoreCog(bot))
|
await bot.add_cog(CoreCog(bot))
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from cachetools import LRUCache
|
from cachetools import LRUCache
|
||||||
|
import itertools
|
||||||
import datetime
|
import datetime
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
from meta import LionCog, LionBot, LionContext
|
from meta import LionCog, LionBot, LionContext
|
||||||
|
from utils.data import MEMBERS
|
||||||
from data import WeakCache
|
from data import WeakCache
|
||||||
|
|
||||||
from .data import CoreData
|
from .data import CoreData
|
||||||
@@ -99,7 +101,7 @@ class Lions(LionCog):
|
|||||||
('guildid',),
|
('guildid',),
|
||||||
*((guildid,) for guildid in missing)
|
*((guildid,) for guildid in missing)
|
||||||
).with_adapter(self.data.Guild._make_rows)
|
).with_adapter(self.data.Guild._make_rows)
|
||||||
rows = (*rows, *new_rows)
|
rows = itertools.chain(rows, new_rows)
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
guildid = row.guildid
|
guildid = row.guildid
|
||||||
@@ -107,6 +109,35 @@ class Lions(LionCog):
|
|||||||
|
|
||||||
return guild_map
|
return guild_map
|
||||||
|
|
||||||
|
async def fetch_users(self, *userids) -> dict[int, LionUser]:
|
||||||
|
"""
|
||||||
|
Fetch (or create) multiple LionUsers simultaneously, using cache where possible.
|
||||||
|
"""
|
||||||
|
user_map = {}
|
||||||
|
missing = set()
|
||||||
|
for userid in userids:
|
||||||
|
luser = self.lion_users.get(userid, None)
|
||||||
|
user_map[userid] = luser
|
||||||
|
if luser is None:
|
||||||
|
missing.add(userid)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
rows = await self.data.User.fetch_where(userid=list(missing))
|
||||||
|
missing.difference_update(row.userid for row in rows)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
new_rows = await self.data.User.table.insert_many(
|
||||||
|
('userid',),
|
||||||
|
*((userid,) for userid in missing)
|
||||||
|
).with_adapter(self.data.user._make_rows)
|
||||||
|
rows = itertools.chain(rows, new_rows)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
userid = row.userid
|
||||||
|
self.lion_users[userid] = user_map[userid] = LionUser(self.bot, row)
|
||||||
|
|
||||||
|
return user_map
|
||||||
|
|
||||||
async def fetch_member(self, guildid, userid, member: Optional[discord.Member] = None) -> LionMember:
|
async def fetch_member(self, guildid, userid, member: Optional[discord.Member] = None) -> LionMember:
|
||||||
"""
|
"""
|
||||||
Fetch the given LionMember, using cache for data if possible.
|
Fetch the given LionMember, using cache for data if possible.
|
||||||
@@ -124,11 +155,46 @@ class Lions(LionCog):
|
|||||||
self.lion_members[key] = lmember
|
self.lion_members[key] = lmember
|
||||||
return lmember
|
return lmember
|
||||||
|
|
||||||
async def fetch_members(self, *members: tuple[int, int]):
|
async def fetch_members(self, *memberids: tuple[int, int]) -> dict[tuple[int, int], LionMember]:
|
||||||
"""
|
"""
|
||||||
Fetch or create multiple members simultaneously.
|
Fetch or create multiple members simultaneously.
|
||||||
"""
|
"""
|
||||||
# TODO: Actually batch this (URGENT)
|
member_map = {}
|
||||||
members = {}
|
missing = set()
|
||||||
for key in members:
|
|
||||||
members[key] = await self.fetch_member(*key)
|
# Retrieve what we can from cache
|
||||||
|
for memberid in memberids:
|
||||||
|
lmember = self.lion_members.get(memberid, None)
|
||||||
|
member_map[memberid] = lmember
|
||||||
|
if lmember is None:
|
||||||
|
missing.add(memberid)
|
||||||
|
|
||||||
|
# Fetch or create members that weren't in cache
|
||||||
|
if missing:
|
||||||
|
# First fetch or create the guilds and users
|
||||||
|
lguilds = await self.fetch_guilds(*(gid for gid, _ in missing))
|
||||||
|
lusers = await self.fetch_users(*(uid for _, uid in missing))
|
||||||
|
|
||||||
|
# Now attempt to load members from data
|
||||||
|
rows = await self.data.Member.fetch_where(MEMBERS(*missing))
|
||||||
|
missing.difference_update((row.guildid, row.userid) for row in rows)
|
||||||
|
|
||||||
|
# Create any member rows that are still missing
|
||||||
|
if missing:
|
||||||
|
new_rows = await self.data.Member.table.insert_many(
|
||||||
|
('guildid', 'userid'),
|
||||||
|
*missing
|
||||||
|
).with_adapter(self.data.Member._make_rows)
|
||||||
|
rows = itertools.chain(rows, new_rows)
|
||||||
|
|
||||||
|
# We have all the data, now construct the member objects
|
||||||
|
for row in rows:
|
||||||
|
key = (row.guildid, row.userid)
|
||||||
|
self.lion_members[key] = member_map[key] = LionMember(
|
||||||
|
self.bot,
|
||||||
|
row,
|
||||||
|
lguilds[row.guildid],
|
||||||
|
lusers[row.userid]
|
||||||
|
)
|
||||||
|
|
||||||
|
return member_map
|
||||||
|
|||||||
64
src/core/setting_types.py
Normal file
64
src/core/setting_types.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""
|
||||||
|
Additional abstract setting types useful for StudyLion settings.
|
||||||
|
"""
|
||||||
|
from settings.setting_types import IntegerSetting
|
||||||
|
from meta import conf
|
||||||
|
from meta.errors import UserInputError
|
||||||
|
from constants import MAX_COINS
|
||||||
|
from babel.translator import ctx_translator
|
||||||
|
|
||||||
|
from . import babel
|
||||||
|
|
||||||
|
_p = babel._p
|
||||||
|
|
||||||
|
|
||||||
|
class CoinSetting(IntegerSetting):
|
||||||
|
"""
|
||||||
|
Setting type mixin describing a LionCoin setting.
|
||||||
|
"""
|
||||||
|
_min = 0
|
||||||
|
_max = MAX_COINS
|
||||||
|
|
||||||
|
_accepts = _p('settype:coin|accepts', "A positive integral number of coins.")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _parse_string(cls, parent_id, string: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Parse the user input into an integer.
|
||||||
|
"""
|
||||||
|
if not string:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
num = int(string)
|
||||||
|
except Exception:
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
|
||||||
|
raise UserInputError(t(_p(
|
||||||
|
'settype:coin|parse|error:notinteger',
|
||||||
|
"The coin quantity must be a positive integer!"
|
||||||
|
))) from None
|
||||||
|
|
||||||
|
if num > cls._max:
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
raise UserInputError(t(_p(
|
||||||
|
'settype:coin|parse|error:too_large',
|
||||||
|
"Provided number of coins was too high!"
|
||||||
|
))) from None
|
||||||
|
elif num < cls._min:
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
raise UserInputError(t(_p(
|
||||||
|
'settype:coin|parse|error:too_large',
|
||||||
|
"Provided number of coins was too low!"
|
||||||
|
))) from None
|
||||||
|
|
||||||
|
return num
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, parent_id, data, **kwargs):
|
||||||
|
if data is not None:
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
formatted = t(_p(
|
||||||
|
'settype:coin|formatted',
|
||||||
|
"{coin}**{amount}**"
|
||||||
|
)).format(coin=conf.emojis.coin, amount=data)
|
||||||
|
return formatted
|
||||||
@@ -137,7 +137,7 @@ class RowModel:
|
|||||||
_registry: Optional[Registry] = None
|
_registry: Optional[Registry] = None
|
||||||
|
|
||||||
# TODO: Proper typing for a classvariable which gets dynamically assigned in subclass
|
# TODO: Proper typing for a classvariable which gets dynamically assigned in subclass
|
||||||
table: RowTable
|
table: RowTable = None
|
||||||
|
|
||||||
def __init_subclass__(cls: Type[RowT], table: Optional[str] = None):
|
def __init_subclass__(cls: Type[RowT], table: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ class TableQuery(Query[QueryResult]):
|
|||||||
"""
|
"""
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
'tableid',
|
'tableid',
|
||||||
'condition', '_extra', '_limit', '_order', '_joins'
|
'condition', '_extra', '_limit', '_order', '_joins', '_from', '_group'
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, tableid, *args, **kwargs):
|
def __init__(self, tableid, *args, **kwargs):
|
||||||
@@ -282,6 +282,26 @@ class LimitMixin(TableQuery[QueryResult]):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FromMixin(TableQuery[QueryResult]):
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._from: Optional[Expression] = None
|
||||||
|
|
||||||
|
def from_expr(self, _from: Expression):
|
||||||
|
self._from = _from
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _from_section(self) -> Optional[Expression]:
|
||||||
|
if self._from is not None:
|
||||||
|
expr, values = self._from.as_tuple()
|
||||||
|
return RawExpr(sql.SQL("FROM {}").format(expr), values)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ORDER(Enum):
|
class ORDER(Enum):
|
||||||
ASC = sql.SQL('ASC')
|
ASC = sql.SQL('ASC')
|
||||||
DESC = sql.SQL('DESC')
|
DESC = sql.SQL('DESC')
|
||||||
@@ -331,6 +351,36 @@ class OrderMixin(TableQuery[QueryResult]):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMixin(TableQuery[QueryResult]):
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self._group: list[Expression] = []
|
||||||
|
|
||||||
|
def group_by(self, *exprs: Union[Expression, str]):
|
||||||
|
"""
|
||||||
|
Add a group expression(s) to the query.
|
||||||
|
This method stacks.
|
||||||
|
"""
|
||||||
|
for expr in exprs:
|
||||||
|
if isinstance(expr, Expression):
|
||||||
|
self._group.append(expr)
|
||||||
|
else:
|
||||||
|
self._group.append(RawExpr(sql.Identifier(expr)))
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _group_section(self) -> Optional[Expression]:
|
||||||
|
if self._group:
|
||||||
|
expr = RawExpr.join(*self._group, joiner=sql.SQL(', '))
|
||||||
|
expr.expr = sql.SQL("GROUP BY {}").format(expr.expr)
|
||||||
|
return expr
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Insert(ExtraMixin, TableQuery[QueryResult]):
|
class Insert(ExtraMixin, TableQuery[QueryResult]):
|
||||||
"""
|
"""
|
||||||
Query type representing a table insert query.
|
Query type representing a table insert query.
|
||||||
@@ -411,7 +461,7 @@ class Insert(ExtraMixin, TableQuery[QueryResult]):
|
|||||||
return RawExpr.join(*sections)
|
return RawExpr.join(*sections)
|
||||||
|
|
||||||
|
|
||||||
class Select(WhereMixin, ExtraMixin, OrderMixin, LimitMixin, JoinMixin, TableQuery[QueryResult]):
|
class Select(WhereMixin, ExtraMixin, OrderMixin, LimitMixin, JoinMixin, GroupMixin, TableQuery[QueryResult]):
|
||||||
"""
|
"""
|
||||||
Select rows from a table matching provided conditions.
|
Select rows from a table matching provided conditions.
|
||||||
"""
|
"""
|
||||||
@@ -464,6 +514,7 @@ class Select(WhereMixin, ExtraMixin, OrderMixin, LimitMixin, JoinMixin, TableQue
|
|||||||
RawExpr(base, columns_values),
|
RawExpr(base, columns_values),
|
||||||
self._join_section,
|
self._join_section,
|
||||||
self._where_section,
|
self._where_section,
|
||||||
|
self._group_section,
|
||||||
self._extra_section,
|
self._extra_section,
|
||||||
self._order_section,
|
self._order_section,
|
||||||
self._limit_section,
|
self._limit_section,
|
||||||
@@ -495,7 +546,7 @@ class Delete(WhereMixin, ExtraMixin, TableQuery[QueryResult]):
|
|||||||
return RawExpr.join(*sections)
|
return RawExpr.join(*sections)
|
||||||
|
|
||||||
|
|
||||||
class Update(LimitMixin, WhereMixin, ExtraMixin, TableQuery[QueryResult]):
|
class Update(LimitMixin, WhereMixin, ExtraMixin, FromMixin, TableQuery[QueryResult]):
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
'_set',
|
'_set',
|
||||||
)
|
)
|
||||||
@@ -534,6 +585,7 @@ class Update(LimitMixin, WhereMixin, ExtraMixin, TableQuery[QueryResult]):
|
|||||||
)
|
)
|
||||||
sections = [
|
sections = [
|
||||||
RawExpr(base, set_values),
|
RawExpr(base, set_values),
|
||||||
|
self._from_section,
|
||||||
self._where_section,
|
self._where_section,
|
||||||
self._extra_section,
|
self._extra_section,
|
||||||
self._limit_section,
|
self._limit_section,
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ class LionBot(Bot):
|
|||||||
"An unexpected error occurred while processing your command!\n"
|
"An unexpected error occurred while processing your command!\n"
|
||||||
"Our development team has been notified, and the issue should be fixed soon.\n"
|
"Our development team has been notified, and the issue should be fixed soon.\n"
|
||||||
"If the error persists, please contact our support team and give them the following number: "
|
"If the error persists, please contact our support team and give them the following number: "
|
||||||
f"`{ctx.interaction.id}`"
|
f"`{ctx.interaction.id if ctx.interaction else ctx.message.id}`"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ active = [
|
|||||||
'.sysadmin',
|
'.sysadmin',
|
||||||
'.config',
|
'.config',
|
||||||
'.user_config',
|
'.user_config',
|
||||||
|
'.schedule',
|
||||||
'.economy',
|
'.economy',
|
||||||
'.ranks',
|
'.ranks',
|
||||||
'.reminders',
|
'.reminders',
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from enum import Enum
|
|||||||
from psycopg import sql
|
from psycopg import sql
|
||||||
from data import Registry, RowModel, RegisterEnum, JOINTYPE, RawExpr
|
from data import Registry, RowModel, RegisterEnum, JOINTYPE, RawExpr
|
||||||
from data.columns import Integer, Bool, Column, Timestamp
|
from data.columns import Integer, Bool, Column, Timestamp
|
||||||
|
|
||||||
from core.data import CoreData
|
from core.data import CoreData
|
||||||
|
from utils.data import TemporaryTable, SAFECOINS
|
||||||
|
|
||||||
|
|
||||||
class TransactionType(Enum):
|
class TransactionType(Enum):
|
||||||
@@ -15,17 +15,25 @@ class TransactionType(Enum):
|
|||||||
'REFUND',
|
'REFUND',
|
||||||
'TRANSFER',
|
'TRANSFER',
|
||||||
'SHOP_PURCHASE',
|
'SHOP_PURCHASE',
|
||||||
'STUDY_SESSION',
|
'VOICE_SESSION',
|
||||||
|
'TEXT_SESSION',
|
||||||
'ADMIN',
|
'ADMIN',
|
||||||
'TASKS'
|
'TASKS',
|
||||||
|
'SCHEDULE_BOOK',
|
||||||
|
'SCHEDULE_REWARD',
|
||||||
|
'OTHER'
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
REFUND = 'REFUND',
|
REFUND = 'REFUND',
|
||||||
TRANSFER = 'TRANSFER',
|
TRANSFER = 'TRANSFER',
|
||||||
PURCHASE = 'SHOP_PURCHASE',
|
SHOP_PURCHASE = 'SHOP_PURCHASE',
|
||||||
SESSION = 'STUDY_SESSION',
|
VOICE_SESSION = 'VOICE_SESSION',
|
||||||
|
TEXT_SESSION = 'TEXT_SESSION',
|
||||||
ADMIN = 'ADMIN',
|
ADMIN = 'ADMIN',
|
||||||
TASKS = 'TASKS',
|
TASKS = 'TASKS',
|
||||||
|
SCHEDULE_BOOK = 'SCHEDULE_BOOK',
|
||||||
|
SCHEDULE_REWARD = 'SCHEDULE_REWARD',
|
||||||
|
OTHER = 'OTHER',
|
||||||
|
|
||||||
|
|
||||||
class AdminActionTarget(Enum):
|
class AdminActionTarget(Enum):
|
||||||
@@ -100,6 +108,8 @@ class EconomyData(Registry, name='economy'):
|
|||||||
from_account: int, to_account: int, amount: int, bonus: int = 0,
|
from_account: int, to_account: int, amount: int, bonus: int = 0,
|
||||||
refunds: int = None
|
refunds: int = None
|
||||||
):
|
):
|
||||||
|
conn = await cls._connector.get_connection()
|
||||||
|
async with conn.transaction():
|
||||||
transaction = await cls.create(
|
transaction = await cls.create(
|
||||||
transactiontype=transaction_type,
|
transactiontype=transaction_type,
|
||||||
guildid=guildid, actorid=actorid, amount=amount, bonus=bonus,
|
guildid=guildid, actorid=actorid, amount=amount, bonus=bonus,
|
||||||
@@ -109,13 +119,89 @@ class EconomyData(Registry, name='economy'):
|
|||||||
if from_account is not None:
|
if from_account is not None:
|
||||||
await CoreData.Member.table.update_where(
|
await CoreData.Member.table.update_where(
|
||||||
guildid=guildid, userid=from_account
|
guildid=guildid, userid=from_account
|
||||||
).set(coins=(CoreData.Member.coins - (amount + bonus)))
|
).set(coins=SAFECOINS(CoreData.Member.coins - (amount + bonus)))
|
||||||
if to_account is not None:
|
if to_account is not None:
|
||||||
await CoreData.Member.table.update_where(
|
await CoreData.Member.table.update_where(
|
||||||
guildid=guildid, userid=to_account
|
guildid=guildid, userid=to_account
|
||||||
).set(coins=(CoreData.Member.coins + (amount + bonus)))
|
).set(coins=SAFECOINS(CoreData.Member.coins + (amount + bonus)))
|
||||||
return transaction
|
return transaction
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def execute_transactions(cls, *transactions):
|
||||||
|
"""
|
||||||
|
Execute multiple transactions in one data transaction.
|
||||||
|
|
||||||
|
Writes the transaction and updates the affected member accounts.
|
||||||
|
Returns the created Transactions.
|
||||||
|
|
||||||
|
Arguments
|
||||||
|
---------
|
||||||
|
transactions: tuple[TransactionType, int, int, int, int, int, int, int]
|
||||||
|
(transaction_type, guildid, actorid, from_account, to_account, amount, bonus, refunds)
|
||||||
|
"""
|
||||||
|
if not transactions:
|
||||||
|
return []
|
||||||
|
|
||||||
|
conn = await cls._connector.get_connection()
|
||||||
|
async with conn.transaction():
|
||||||
|
# Create the transactions
|
||||||
|
rows = await cls.table.insert_many(
|
||||||
|
(
|
||||||
|
'transactiontype',
|
||||||
|
'guildid', 'actorid',
|
||||||
|
'from_account', 'to_account',
|
||||||
|
'amount', 'bonus',
|
||||||
|
'refunds'
|
||||||
|
),
|
||||||
|
*transactions
|
||||||
|
).with_adapter(cls._make_rows)
|
||||||
|
|
||||||
|
# Update the members
|
||||||
|
transtable = TemporaryTable(
|
||||||
|
'_guildid', '_userid', '_amount',
|
||||||
|
types=('BIGINT', 'BIGINT', 'INTEGER')
|
||||||
|
)
|
||||||
|
values = transtable.values
|
||||||
|
for transaction in transactions:
|
||||||
|
_, guildid, _, from_acc, to_acc, amount, bonus, _ = transaction
|
||||||
|
coins = amount + bonus
|
||||||
|
if coins:
|
||||||
|
if from_acc:
|
||||||
|
values.append((guildid, from_acc, -1 * coins))
|
||||||
|
if to_acc:
|
||||||
|
values.append((guildid, to_acc, coins))
|
||||||
|
if values:
|
||||||
|
Member = CoreData.Member
|
||||||
|
await Member.table.update_where(
|
||||||
|
guildid=transtable['_guildid'], userid=transtable['_userid']
|
||||||
|
).set(
|
||||||
|
coins=SAFECOINS(Member.coins + transtable['_amount'])
|
||||||
|
).from_expr(transtable)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def refund_transactions(cls, *transactionids, actorid=0):
|
||||||
|
if not transactionids:
|
||||||
|
return []
|
||||||
|
conn = await cls._connector.get_connection()
|
||||||
|
async with conn.transaction():
|
||||||
|
# First fetch the transaction rows to refund
|
||||||
|
data = await cls.table.select_where(transactionid=transactionids)
|
||||||
|
if data:
|
||||||
|
# Build the transaction refund data
|
||||||
|
records = [
|
||||||
|
(
|
||||||
|
TransactionType.REFUND,
|
||||||
|
tr['guildid'], actorid,
|
||||||
|
tr['to_account'], tr['from_account'],
|
||||||
|
tr['amount'] + tr['bonus'], 0,
|
||||||
|
tr['transactionid']
|
||||||
|
)
|
||||||
|
for tr in data
|
||||||
|
]
|
||||||
|
# Execute refund transactions
|
||||||
|
return await cls.execute_transactions(*records)
|
||||||
|
|
||||||
class ShopTransaction(RowModel):
|
class ShopTransaction(RowModel):
|
||||||
"""
|
"""
|
||||||
Schema
|
Schema
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ class Timer:
|
|||||||
hook = self._hook = await self.bot.core.data.LionHook.fetch(cid)
|
hook = self._hook = await self.bot.core.data.LionHook.fetch(cid)
|
||||||
if not hook:
|
if not hook:
|
||||||
# Attempt to create and save webhook
|
# Attempt to create and save webhook
|
||||||
|
# TODO: Localise
|
||||||
try:
|
try:
|
||||||
if channel.permissions_for(channel.guild.me).manage_webhooks:
|
if channel.permissions_for(channel.guild.me).manage_webhooks:
|
||||||
avatar = self.bot.user.avatar
|
avatar = self.bot.user.avatar
|
||||||
|
|||||||
10
src/modules/schedule/__init__.py
Normal file
10
src/modules/schedule/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import logging
|
||||||
|
from babel.translator import LocalBabel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
babel = LocalBabel('schedule')
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(bot):
|
||||||
|
from .cog import ScheduleCog
|
||||||
|
await bot.add_cog(ScheduleCog(bot))
|
||||||
882
src/modules/schedule/cog.py
Normal file
882
src/modules/schedule/cog.py
Normal file
@@ -0,0 +1,882 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from weakref import WeakValueDictionary
|
||||||
|
import datetime as dt
|
||||||
|
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 UserInputError, ResponseTimedOut
|
||||||
|
from meta.sharding import THIS_SHARD
|
||||||
|
from utils.lib import utc_now, error_embed
|
||||||
|
from utils.ui import Confirm
|
||||||
|
from utils.data import MULTIVALUE_IN, MEMBERS
|
||||||
|
from wards import low_management_ward
|
||||||
|
from core.data import CoreData
|
||||||
|
from data import NULL, ORDER
|
||||||
|
from modules.economy.data import TransactionType
|
||||||
|
from constants import MAX_COINS
|
||||||
|
|
||||||
|
from . import babel, logger
|
||||||
|
from .data import ScheduleData
|
||||||
|
from .settings import ScheduleSettings, ScheduleConfig
|
||||||
|
from .ui.scheduleui import ScheduleUI
|
||||||
|
from .ui.settingui import ScheduleSettingUI
|
||||||
|
from .core import TimeSlot, ScheduledSession, SessionMember
|
||||||
|
from .lib import slotid_to_utc, time_to_slotid
|
||||||
|
|
||||||
|
_p, _np = babel._p, babel._np
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleCog(LionCog):
|
||||||
|
def __init__(self, bot: LionBot):
|
||||||
|
self.bot = bot
|
||||||
|
self.data: ScheduleData = bot.db.load_registry(ScheduleData())
|
||||||
|
self.settings = ScheduleSettings()
|
||||||
|
|
||||||
|
# Whether we are ready to take events
|
||||||
|
self.initialised = asyncio.Event()
|
||||||
|
|
||||||
|
# Activated slot cache
|
||||||
|
self.active_slots: dict[int, TimeSlot] = {} # slotid -> TimeSlot
|
||||||
|
|
||||||
|
# External modification (including spawing) a slot requires holding a slot lock
|
||||||
|
self._slot_locks = WeakValueDictionary()
|
||||||
|
|
||||||
|
# Modifying a non-running slot or session requires holding the spawn lock
|
||||||
|
# This ensures the slot will not start while being modified
|
||||||
|
self.spawn_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# Spawner loop task
|
||||||
|
self.spawn_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
self.session_channels = self.settings.SessionChannels._cache
|
||||||
|
|
||||||
|
async def cog_load(self):
|
||||||
|
await self.data.init()
|
||||||
|
|
||||||
|
# Update the session channel cache
|
||||||
|
await self.settings.SessionChannels.setup(self.bot)
|
||||||
|
|
||||||
|
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 session spawning and the ongoing sessions.
|
||||||
|
"""
|
||||||
|
# TODO: Test/design for reload
|
||||||
|
if self.spawn_task and not self.spawn_task.done():
|
||||||
|
self.spawn_task.cancel()
|
||||||
|
|
||||||
|
for slot in list(self.active_slots.values()):
|
||||||
|
if slot.run_task and not slot.run_task.done():
|
||||||
|
slot.run_task.cancel()
|
||||||
|
for session in slot.sessions.values():
|
||||||
|
if session._updater and not session._updater.done():
|
||||||
|
session._update.cancel()
|
||||||
|
if session._status_task and not session._status_task.done():
|
||||||
|
session._status_task.cancel()
|
||||||
|
|
||||||
|
@LionCog.listener('on_ready')
|
||||||
|
@log_wrap(action='Init Schedule')
|
||||||
|
async def initialise(self):
|
||||||
|
"""
|
||||||
|
Launch current timeslots, cleanup missed timeslots, and start the spawner.
|
||||||
|
"""
|
||||||
|
# Wait until voice session tracker has initialised
|
||||||
|
tracker = self.bot.get_cog('VoiceTrackerCog')
|
||||||
|
await tracker.initialised.wait()
|
||||||
|
|
||||||
|
# Spawn the current session
|
||||||
|
now = utc_now()
|
||||||
|
nowid = time_to_slotid(now)
|
||||||
|
await self._spawner(nowid)
|
||||||
|
|
||||||
|
# Start the spawner, with a small jitter based on shard id (for db loading)
|
||||||
|
spawn_start = now.replace(minute=30, second=0, microsecond=0)
|
||||||
|
spawn_start += dt.timedelta(seconds=self.bot.shard_id * 10)
|
||||||
|
self.spawn_task = asyncio.create_task(self._spawn_loop(start_at=spawn_start))
|
||||||
|
|
||||||
|
# Cleanup after missed or delayed timeslots
|
||||||
|
model = self.data.ScheduleSession
|
||||||
|
missed_session_data = await model.fetch_where(
|
||||||
|
model.slotid < nowid,
|
||||||
|
model.slotid > (nowid - 24 * 60 * 60),
|
||||||
|
model.closed_at == NULL,
|
||||||
|
THIS_SHARD
|
||||||
|
)
|
||||||
|
if missed_session_data:
|
||||||
|
# Partition by slotid
|
||||||
|
slotid_session_data = defaultdict(list)
|
||||||
|
for row in missed_session_data:
|
||||||
|
slotid_session_data[row.slotid].append(row)
|
||||||
|
|
||||||
|
# Fetch associated TimeSlots, oldest first
|
||||||
|
slot_data = await self.data.ScheduleSlot.fetch_where(
|
||||||
|
slotid=list(slotid_session_data.keys())
|
||||||
|
).order_by('slotid')
|
||||||
|
|
||||||
|
# Process each slot
|
||||||
|
for row in slot_data:
|
||||||
|
try:
|
||||||
|
slot = TimeSlot(self, row)
|
||||||
|
sessions = await slot.load_sessions(slotid_session_data[slot.slotid])
|
||||||
|
await slot.cleanup(list(sessions.values()))
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
f"Unhandled exception while cleaning up missed timeslot {row!r}"
|
||||||
|
)
|
||||||
|
self.initialised.set()
|
||||||
|
|
||||||
|
@log_wrap(stack=['Schedule Spawner'])
|
||||||
|
async def _spawn_loop(self, start_at: dt.datetime):
|
||||||
|
"""
|
||||||
|
Every hour, starting at start_at,
|
||||||
|
the spawn loop will use `_spawner` to ensure the next slotid has been launched.
|
||||||
|
"""
|
||||||
|
next_spawn = start_at
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await discord.utils.sleep_until(next_spawn)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
next_spawn = next_spawn + dt.timedelta(hours=1)
|
||||||
|
try:
|
||||||
|
nextid = time_to_slotid(next_spawn)
|
||||||
|
await self._spawner(nextid)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Unexpected error occurred while spawning scheduled sessions."
|
||||||
|
)
|
||||||
|
|
||||||
|
@log_wrap(action='Spawn')
|
||||||
|
async def _spawner(self, slotid):
|
||||||
|
"""
|
||||||
|
Ensure the provided slotid exists and is running.
|
||||||
|
"""
|
||||||
|
async with self.slotlock(slotid):
|
||||||
|
slot = self.active_slots.get(slotid, None)
|
||||||
|
if slot is None or slot.run_task is None:
|
||||||
|
slot_data = await self.data.ScheduleSlot.fetch_or_create(slotid)
|
||||||
|
slot = TimeSlot(self, slot_data)
|
||||||
|
await slot.fetch()
|
||||||
|
self.active_slots[slotid] = slot
|
||||||
|
self._launch(slot)
|
||||||
|
logger.info(f"Spawned Schedule TimeSlot <slotid: {slotid}>")
|
||||||
|
|
||||||
|
def _launch(self, slot: TimeSlot):
|
||||||
|
launch_task = slot.launch()
|
||||||
|
key = slot.slotid
|
||||||
|
launch_task.add_done_callback(lambda fut: self.active_slots.pop(key, None))
|
||||||
|
|
||||||
|
# API
|
||||||
|
def slotlock(self, slotid):
|
||||||
|
lock = self._slot_locks.get(slotid, None)
|
||||||
|
if lock is None:
|
||||||
|
lock = self._slot_locks[slotid] = asyncio.Lock()
|
||||||
|
return lock
|
||||||
|
|
||||||
|
@log_wrap(action='Cancel Booking')
|
||||||
|
async def cancel_bookings(self, *bookingids: tuple[int, int, int], refund=True):
|
||||||
|
"""
|
||||||
|
Cancel the provided bookings.
|
||||||
|
|
||||||
|
bookingid: tuple[int, int, int]
|
||||||
|
Tuple of (slotid, guildid, userid)
|
||||||
|
"""
|
||||||
|
slotids = set(bookingid[0] for bookingid in bookingids)
|
||||||
|
locks = [self.slotlock(slotid) for slotid in slotids]
|
||||||
|
|
||||||
|
# Request all relevant slotlocks
|
||||||
|
await asyncio.gather(*(lock.acquire() for lock in locks))
|
||||||
|
try:
|
||||||
|
# TODO: Some benchmarking here
|
||||||
|
# Should we do the channel updates in bulk?
|
||||||
|
for bookingid in bookingids:
|
||||||
|
await self._cancel_booking_active(*bookingid)
|
||||||
|
|
||||||
|
# Now delete from data
|
||||||
|
records = await self.data.ScheduleSessionMember.table.delete_where(
|
||||||
|
MULTIVALUE_IN(
|
||||||
|
('slotid', 'guildid', 'userid'),
|
||||||
|
*bookingids
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refund cancelled bookings
|
||||||
|
if refund:
|
||||||
|
maybe_tids = (record['book_transactionid'] for record in records)
|
||||||
|
tids = [tid for tid in maybe_tids if tid is not None]
|
||||||
|
if tids:
|
||||||
|
economy = self.bot.get_cog('Economy')
|
||||||
|
await economy.data.Transaction.refund_transactions(*tids)
|
||||||
|
finally:
|
||||||
|
for lock in locks:
|
||||||
|
lock.release()
|
||||||
|
return records
|
||||||
|
|
||||||
|
async def _cancel_booking_active(self, slotid, guildid, userid):
|
||||||
|
"""
|
||||||
|
Booking cancel worker for active slots.
|
||||||
|
|
||||||
|
Does nothing if the provided bookingid is not active.
|
||||||
|
The slot lock MUST be taken before this is run.
|
||||||
|
"""
|
||||||
|
if not self.slotlock(slotid).locked():
|
||||||
|
raise ValueError("Attempting to cancel active booking without taking slotlock.")
|
||||||
|
|
||||||
|
slot = self.active_slots.get(slotid, None)
|
||||||
|
session = slot.sessions.get(guildid, None) if slot else None
|
||||||
|
member = session.pop(userid, None) if session else None
|
||||||
|
if member is not None:
|
||||||
|
if slot.closing.is_set():
|
||||||
|
# Don't try to cancel a booking for a closing active slot.
|
||||||
|
return
|
||||||
|
async with session.lock:
|
||||||
|
# Update message if it has already been sent
|
||||||
|
session.update_message_soon(resend=False)
|
||||||
|
room = session.room_channel
|
||||||
|
member = session.guild.get_member(userid) if room else None
|
||||||
|
if room and member and session.prepared:
|
||||||
|
# Update channel permissions unless the member is in the next session and it is prepared
|
||||||
|
nextslotid = slotid + 3600
|
||||||
|
nextslot = self.active_slots.get(nextslotid, None)
|
||||||
|
nextsession = nextslot.sessions.get(guildid, None) if nextslot else None
|
||||||
|
nextmember = (userid in nextsession.members) if nextsession else None
|
||||||
|
|
||||||
|
unlock = None
|
||||||
|
try:
|
||||||
|
if nextmember:
|
||||||
|
unlock = nextsession.lock
|
||||||
|
await unlock.acquire()
|
||||||
|
update = (not nextsession.prepared)
|
||||||
|
else:
|
||||||
|
update = True
|
||||||
|
if update:
|
||||||
|
await room.set_permissions(member, overwrite=None)
|
||||||
|
except discord.HTTPException:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if unlock is not None:
|
||||||
|
unlock.release()
|
||||||
|
elif slot is not None and member is None:
|
||||||
|
# Should not happen
|
||||||
|
logger.error(
|
||||||
|
f"Cancelling booking <slotid: {slotid}> <gid: {guildid}> <uid: {userid}> "
|
||||||
|
"for active slot "
|
||||||
|
"but the session member was not found. This should not happen."
|
||||||
|
)
|
||||||
|
|
||||||
|
@log_wrap(action='Clear Member Schedule')
|
||||||
|
async def clear_member_schedule(self, guildid, userid, refund=False):
|
||||||
|
"""
|
||||||
|
Cancel all current and future bookings for the given member.
|
||||||
|
"""
|
||||||
|
now = utc_now()
|
||||||
|
nowid = time_to_slotid(now)
|
||||||
|
|
||||||
|
# First retrieve current and future booking data
|
||||||
|
bookings = await self.data.ScheduleSessionMember.fetch_where(
|
||||||
|
(ScheduleData.ScheduleSessionMember.slotid >= nowid),
|
||||||
|
guildid=guildid,
|
||||||
|
userid=userid,
|
||||||
|
)
|
||||||
|
bookingids = [(b.slotid, guildid, userid) for b in bookings]
|
||||||
|
if bookingids:
|
||||||
|
await self.cancel_bookings(*bookingids, refund=refund)
|
||||||
|
|
||||||
|
@log_wrap(action='Handle NoShow')
|
||||||
|
async def handle_noshow(self, *memberids):
|
||||||
|
"""
|
||||||
|
Handle "did not show" members.
|
||||||
|
|
||||||
|
Typically cancels all future sessions for this member,
|
||||||
|
blacklists depending on guild settings,
|
||||||
|
and notifies the user.
|
||||||
|
"""
|
||||||
|
now = utc_now()
|
||||||
|
nowid = time_to_slotid(now)
|
||||||
|
member_model = self.data.ScheduleSessionMember
|
||||||
|
|
||||||
|
# First handle blacklist
|
||||||
|
guildids, userids = map(set, zip(*memberids))
|
||||||
|
# This should hit cache
|
||||||
|
config_data = await self.data.ScheduleGuild.fetch_multiple(*guildids)
|
||||||
|
autoblacklisting = {}
|
||||||
|
for gid, row in config_data.items():
|
||||||
|
if row['blacklist_after'] and (rid := row['blacklist_role']):
|
||||||
|
guild = self.bot.get_guild(gid)
|
||||||
|
role = guild.get_role(rid) if guild else None
|
||||||
|
if role is not None:
|
||||||
|
autoblacklisting[gid] = (row['blacklist_after'], role)
|
||||||
|
|
||||||
|
to_blacklist = {}
|
||||||
|
if autoblacklisting:
|
||||||
|
# Count number of missed sessions in the last 24h for each member in memberids
|
||||||
|
# who is also in an autoblacklisting guild
|
||||||
|
members = {}
|
||||||
|
for gid, uid in memberids:
|
||||||
|
if gid in autoblacklisting:
|
||||||
|
guild = self.bot.get_guild(gid)
|
||||||
|
member = guild.get_member(uid) if guild else None
|
||||||
|
if member:
|
||||||
|
members[(gid, uid)] = member
|
||||||
|
|
||||||
|
if members:
|
||||||
|
missed = await member_model.table.select_where(
|
||||||
|
member_model.slotid < nowid,
|
||||||
|
member_model.slotid >= nowid - 24 * 3600,
|
||||||
|
MEMBERS(*members.keys()),
|
||||||
|
attended=False,
|
||||||
|
).select(
|
||||||
|
guildid=member_model.guildid,
|
||||||
|
userid=member_model.userid,
|
||||||
|
missed="COUNT(slotid)"
|
||||||
|
).group_by(member_model.guildid, member_model.userid).with_no_adapter()
|
||||||
|
for row in missed:
|
||||||
|
if row['missed'] >= autoblacklisting[row['guildid']][0]:
|
||||||
|
key = (row['guildid'], row['userid'])
|
||||||
|
to_blacklist[key] = members[key]
|
||||||
|
|
||||||
|
if to_blacklist:
|
||||||
|
# Actually apply blacklist
|
||||||
|
tasks = []
|
||||||
|
for (gid, uid), member in to_blacklist.items():
|
||||||
|
role = autoblacklisting[gid][1]
|
||||||
|
task = asyncio.create_task(member.add_role(role))
|
||||||
|
tasks.append(task)
|
||||||
|
# TODO: Logging and some error handling
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
# Now cancel future sessions for members who were not blacklisted and are not currently clocked on
|
||||||
|
to_clear = []
|
||||||
|
activeslot = self.active_slots[nowid]
|
||||||
|
for mid in memberids:
|
||||||
|
if mid not in to_blacklist:
|
||||||
|
gid, uid = mid
|
||||||
|
session = activeslot.sessions.get(gid, None)
|
||||||
|
member = session.members.get(uid, None) if session else None
|
||||||
|
clocked = (member is not None) and (member.clock_start is not None)
|
||||||
|
if not clocked:
|
||||||
|
to_clear.append(mid)
|
||||||
|
|
||||||
|
if to_clear:
|
||||||
|
# Retrieve booking data
|
||||||
|
bookings = await member_model.fetch_where(
|
||||||
|
(member_model.slotid >= nowid),
|
||||||
|
MEMBERS(*to_clear)
|
||||||
|
)
|
||||||
|
bookingids = [(b.slotid, b.guildid, b.userid) for b in bookings]
|
||||||
|
if bookingids:
|
||||||
|
await self.cancel_bookings(*bookingids, refund=False)
|
||||||
|
# TODO: Logging and error handling
|
||||||
|
|
||||||
|
@log_wrap(action='Create Booking')
|
||||||
|
async def create_booking(self, guildid, userid, *slotids):
|
||||||
|
"""
|
||||||
|
Create new bookings with the given bookingids.
|
||||||
|
|
||||||
|
Probably best refactored into an interactive method,
|
||||||
|
with some parts in slot and session.
|
||||||
|
"""
|
||||||
|
t = self.bot.translator.t
|
||||||
|
locks = [self.slotlock(slotid) for slotid in slotids]
|
||||||
|
await asyncio.gather(*(lock.acquire() for lock in locks))
|
||||||
|
try:
|
||||||
|
conn = await self.bot.db.get_connection()
|
||||||
|
async with conn.transaction():
|
||||||
|
# Validate bookings
|
||||||
|
guild_data = await self.data.ScheduleGuild.fetch_or_create(guildid)
|
||||||
|
config = ScheduleConfig(guildid, guild_data)
|
||||||
|
|
||||||
|
# Check guild lobby exists
|
||||||
|
if config.get(ScheduleSettings.SessionLobby.setting_id).value is None:
|
||||||
|
error = t(_p(
|
||||||
|
'create_booking|error:no_lobby',
|
||||||
|
"This server has not set a `session_lobby`, so the scheduled session system is disabled!"
|
||||||
|
))
|
||||||
|
raise UserInputError(error)
|
||||||
|
|
||||||
|
# Fetch up to data lion data and member data
|
||||||
|
lion = await self.bot.core.lions.fetch_member(guildid, userid)
|
||||||
|
member = await lion.fetch_member()
|
||||||
|
await lion.data.refresh()
|
||||||
|
if not member:
|
||||||
|
# This should pretty much never happen unless something went wrong on Discord's end
|
||||||
|
error = t(_p(
|
||||||
|
'create_booking|error:no_member',
|
||||||
|
"An unknown Discord error occurred. Please try again in a few minutes."
|
||||||
|
))
|
||||||
|
raise UserInputError(error)
|
||||||
|
|
||||||
|
# Check member blacklist
|
||||||
|
if (role := config.get(ScheduleSettings.BlacklistRole.setting_id).value) and role in member.roles:
|
||||||
|
error = t(_p(
|
||||||
|
'create_booking|error:blacklisted',
|
||||||
|
"You have been blacklisted from the scheduled session system in this server."
|
||||||
|
))
|
||||||
|
raise UserInputError(error)
|
||||||
|
|
||||||
|
# Check member balance
|
||||||
|
requested = len(slotids)
|
||||||
|
required = len(slotids) * config.get(ScheduleSettings.ScheduleCost.setting_id).value
|
||||||
|
balance = lion.data.coins
|
||||||
|
if balance < required:
|
||||||
|
error = t(_np(
|
||||||
|
'create_booking|error:insufficient_balance',
|
||||||
|
"Booking a session costs {coin}**{required}**, but you only have {coin}**{balance}**.",
|
||||||
|
"Booking `{count}` sessions costs {coin}**{required}**, but you only have {coin}**{balance}**.",
|
||||||
|
requested
|
||||||
|
)).format(
|
||||||
|
count=requested, coin=self.bot.config.emojis.coin,
|
||||||
|
required=required, balance=balance
|
||||||
|
)
|
||||||
|
raise UserInputError(error)
|
||||||
|
|
||||||
|
# Check existing bookings
|
||||||
|
schedule = await self._fetch_schedule(userid)
|
||||||
|
if set(slotids).intersection(schedule.keys()):
|
||||||
|
error = t(_p(
|
||||||
|
'create_booking|error:already_booked',
|
||||||
|
"One or more requested timeslots are already booked!"
|
||||||
|
))
|
||||||
|
raise UserInputError(error)
|
||||||
|
|
||||||
|
# Booking request is now validated. Perform bookings.
|
||||||
|
|
||||||
|
# Fetch or create session data
|
||||||
|
await self.data.ScheduleSlot.fetch_multiple(*slotids)
|
||||||
|
session_data = await self.data.ScheduleSession.fetch_multiple(
|
||||||
|
*((guildid, slotid) for slotid in slotids)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create transactions
|
||||||
|
economy = self.bot.get_cog('Economy')
|
||||||
|
trans_data = (
|
||||||
|
TransactionType.SCHEDULE_BOOK,
|
||||||
|
guildid, userid, userid, 0,
|
||||||
|
config.get(ScheduleSettings.ScheduleCost.setting_id).value,
|
||||||
|
0, None
|
||||||
|
)
|
||||||
|
transactions = await economy.data.Transaction.execute_transactions(*(trans_data for _ in slotids))
|
||||||
|
transactionids = [row.transactionid for row in transactions]
|
||||||
|
|
||||||
|
# Create bookings
|
||||||
|
now = utc_now()
|
||||||
|
booking_data = await self.data.ScheduleSessionMember.table.insert_many(
|
||||||
|
('guildid', 'userid', 'slotid', 'booked_at', 'book_transactionid'),
|
||||||
|
*(
|
||||||
|
(guildid, userid, slotid, now, tid)
|
||||||
|
for slotid, tid in zip(slotids, transactionids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now pass to activated slots
|
||||||
|
for record in booking_data:
|
||||||
|
slotid = record['slotid']
|
||||||
|
if (slot := self.active_slots.get(slotid, None)):
|
||||||
|
session = slot.sessions.get(guildid, None)
|
||||||
|
if session is None:
|
||||||
|
# Create a new session in the slot and set it up
|
||||||
|
session = await slot.load_sessions(session_data[guildid, slotid])
|
||||||
|
slot.sessions[guildid] = session
|
||||||
|
if slot.closing.is_set():
|
||||||
|
# This should never happen
|
||||||
|
logger.error(
|
||||||
|
"Attempt to book a session in a closing slot. This should be impossible."
|
||||||
|
)
|
||||||
|
raise ValueError('Cannot book a session in a closing slot.')
|
||||||
|
elif slot.opening.is_set():
|
||||||
|
await slot.open([session])
|
||||||
|
elif slot.preparing.is_set():
|
||||||
|
await slot.prepare([session])
|
||||||
|
else:
|
||||||
|
# Session already exists in the slot
|
||||||
|
async with session.lock:
|
||||||
|
if session.prepared:
|
||||||
|
session.update_status_soon()
|
||||||
|
if (room := session.room_channel) and (mem := session.guild.get_member(userid)):
|
||||||
|
try:
|
||||||
|
await room.set_permissions(
|
||||||
|
mem, connect=True, view_channel=True
|
||||||
|
)
|
||||||
|
except discord.HTTPException:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
for lock in locks:
|
||||||
|
lock.release()
|
||||||
|
# TODO: Logging and error handling
|
||||||
|
return booking_data
|
||||||
|
|
||||||
|
# Event listeners
|
||||||
|
@LionCog.listener('on_member_update')
|
||||||
|
@log_wrap(action="Schedule Check Blacklist")
|
||||||
|
async def check_blacklist_role(self, before: discord.Member, after: discord.Member):
|
||||||
|
guild = before.guild
|
||||||
|
await self.initialised.wait()
|
||||||
|
before_roles = {role.id for role in before.roles}
|
||||||
|
new_roles = {role.id for role in after.roles if role.id not in before_roles}
|
||||||
|
if new_roles:
|
||||||
|
# This should be in cache in the vast majority of cases
|
||||||
|
guild_data = await self.data.ScheduleGuild.fetch(guild.id)
|
||||||
|
if (roleid := guild_data.blacklist_role) is not None and roleid in new_roles:
|
||||||
|
# Clear member schedule
|
||||||
|
await self.clear_member_schedule(guild.id, after.id)
|
||||||
|
|
||||||
|
@LionCog.listener('on_member_remove')
|
||||||
|
@log_wrap(action="Schedule Member Remove")
|
||||||
|
async def clear_leaving_member(self, member: discord.Member):
|
||||||
|
"""
|
||||||
|
When a member leaves, clear their schedule
|
||||||
|
"""
|
||||||
|
await self.initialised.wait()
|
||||||
|
await self.clear_member_schedule(member.guild.id, member.id, refund=True)
|
||||||
|
|
||||||
|
@LionCog.listener('on_guild_remove')
|
||||||
|
@log_wrap(action="Schedule Guild Remove")
|
||||||
|
async def clear_leaving_guild(self, guild: discord.Guild):
|
||||||
|
"""
|
||||||
|
When leaving a guild, delete all future bookings in the guild.
|
||||||
|
|
||||||
|
This avoids penalising members for missing sessions in guilds we are not part of.
|
||||||
|
However, do not delete the guild sessions,
|
||||||
|
this allows seamless resuming if we rejoin the guild (aside from the cancelled sessions).
|
||||||
|
|
||||||
|
Note that loaded sessions are independent of whether we are in the guild or not
|
||||||
|
(rather, we load all sessions that match this shard).
|
||||||
|
Hence we do not need to recreate the sessions when we join a new guild.
|
||||||
|
"""
|
||||||
|
await self.initialised.wait()
|
||||||
|
|
||||||
|
now = utc_now()
|
||||||
|
nowid = time_to_slotid(now)
|
||||||
|
|
||||||
|
bookings = await self.data.ScheduleSessionMember.fetch_where(
|
||||||
|
(ScheduleData.ScheduleSessionMember.slotid >= nowid),
|
||||||
|
guildid=guild.id
|
||||||
|
)
|
||||||
|
bookingids = [(b.slotid, b.guildid, b.userid) for b in bookings]
|
||||||
|
if bookingids:
|
||||||
|
await self.cancel_bookings(*bookingids, refund=True)
|
||||||
|
|
||||||
|
@LionCog.listener('on_voice_session_start')
|
||||||
|
@log_wrap(action="Schedule Clock On")
|
||||||
|
async def schedule_clockon(self, session_data):
|
||||||
|
try:
|
||||||
|
# DEBUG
|
||||||
|
logger.debug(f"Handling clock on parsing for {session_data}")
|
||||||
|
# Get current slot
|
||||||
|
now = utc_now()
|
||||||
|
nowid = time_to_slotid(now)
|
||||||
|
async with self.slotlock(nowid):
|
||||||
|
slot = self.active_slots.get(nowid, None)
|
||||||
|
if slot is not None:
|
||||||
|
# Get session in current slot
|
||||||
|
session = slot.sessions.get(session_data.guildid, None)
|
||||||
|
member = session.members.get(session_data.userid, None) if session else None
|
||||||
|
if member is not None:
|
||||||
|
async with session.lock:
|
||||||
|
if session.listening and session.validate_channel(session_data.channelid):
|
||||||
|
member.clock_on(session_data.start_time)
|
||||||
|
session.update_status_soon()
|
||||||
|
logger.debug(
|
||||||
|
f"Clocked on member {member.data!r} with session {session_data!r}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
f"Unexpected exception while clocking on voice sessions {session_data!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@LionCog.listener('on_voice_session_end')
|
||||||
|
@log_wrap(action="Schedule Clock Off")
|
||||||
|
async def schedule_clockoff(self, session_data, ended_at):
|
||||||
|
try:
|
||||||
|
# DEBUG
|
||||||
|
logger.debug(f"Handling clock off parsing for {session_data}")
|
||||||
|
# Get current slot
|
||||||
|
now = utc_now()
|
||||||
|
nowid = time_to_slotid(now)
|
||||||
|
async with self.slotlock(nowid):
|
||||||
|
slot = self.active_slots.get(nowid, None)
|
||||||
|
if slot is not None:
|
||||||
|
# Get session in current slot
|
||||||
|
session = slot.sessions.get(session_data.guildid)
|
||||||
|
member = session.members.get(session_data.userid, None) if session else None
|
||||||
|
if member is not None:
|
||||||
|
async with session.lock:
|
||||||
|
if session.listening and session.validate_channel(session_data.channelid):
|
||||||
|
member.clock_off(ended_at)
|
||||||
|
session.update_status_soon()
|
||||||
|
logger.debug(
|
||||||
|
f"Clocked off member {member.data!r} from session {session_data!r}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
f"Unexpected exception while clocking off voice sessions {session_data!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Schedule commands
|
||||||
|
@cmds.hybrid_command(
|
||||||
|
name=_p('cmd:schedule', "schedule"),
|
||||||
|
description=_p(
|
||||||
|
'cmd:schedule|desc',
|
||||||
|
"View and manage your scheduled session."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@appcmds.guild_only
|
||||||
|
async def schedule_cmd(self, ctx: LionContext):
|
||||||
|
# TODO: Auotocomplete for book and cancel options
|
||||||
|
# Will require TTL caching for member schedules.
|
||||||
|
book = None
|
||||||
|
cancel = None
|
||||||
|
if not ctx.guild:
|
||||||
|
return
|
||||||
|
if not ctx.interaction:
|
||||||
|
return
|
||||||
|
|
||||||
|
t = self.bot.translator.t
|
||||||
|
guildid = ctx.guild.id
|
||||||
|
guild_data = await self.data.ScheduleGuild.fetch_or_create(guildid)
|
||||||
|
config = ScheduleConfig(guildid, guild_data)
|
||||||
|
now = utc_now()
|
||||||
|
lines: list[tuple[bool, str]] = [] # (error_status, msg)
|
||||||
|
|
||||||
|
if cancel is not None:
|
||||||
|
schedule = await self._fetch_schedule(ctx.author.id)
|
||||||
|
# Validate provided
|
||||||
|
if not cancel.isdigit():
|
||||||
|
# Error, slot {cancel} not recognised, please select a session to cancel from the acmpl list.
|
||||||
|
error = t(_p(
|
||||||
|
'cmd:schedule|cancel_booking|error:parse_slot',
|
||||||
|
"Time slot `{provided}` not recognised. "
|
||||||
|
"Please select a session to cancel from the autocomplete options."
|
||||||
|
))
|
||||||
|
line = (True, error)
|
||||||
|
elif (slotid := int(cancel)) not in schedule:
|
||||||
|
# Can't cancel slot because it isn't booked
|
||||||
|
error = t(_p(
|
||||||
|
'cmd:schedule|cancel_booking|error:not_booked',
|
||||||
|
"Could not cancel {time} booking because it is not booked!"
|
||||||
|
)).format(
|
||||||
|
time=discord.utils.format_dt(slotid_to_utc(slotid), style='t')
|
||||||
|
)
|
||||||
|
line = (True, error)
|
||||||
|
elif (slotid_to_utc(slotid) - now).total_seconds() < 60:
|
||||||
|
# Can't cancel slot because it is running or about to start
|
||||||
|
error = t(_p(
|
||||||
|
'cmd:schedule|cancel_booking|error:too_soon',
|
||||||
|
"Cannot cancel {time} booking because it is running or starting soon!"
|
||||||
|
)).format(
|
||||||
|
time=discord.utils.format_dt(slotid_to_utc(slotid), style='t')
|
||||||
|
)
|
||||||
|
line = (True, error)
|
||||||
|
else:
|
||||||
|
# Okay, slot is booked and cancellable.
|
||||||
|
# Actually cancel it
|
||||||
|
booking = schedule[slotid]
|
||||||
|
await self.cancel_bookings((booking.slotid, booking.guildid, booking.userid))
|
||||||
|
# Confirm cancel done
|
||||||
|
ack = t(_p(
|
||||||
|
'cmd:schedule|cancel_booking|success',
|
||||||
|
"Successfully cancelled your booking at {time}."
|
||||||
|
)).format(
|
||||||
|
time=discord.utils.format_dt(slotid_to_utc(slotid), style='t')
|
||||||
|
)
|
||||||
|
line = (False, ack)
|
||||||
|
lines.append(line)
|
||||||
|
|
||||||
|
if book is not None:
|
||||||
|
schedule = await self._fetch_schedule(ctx.author.id)
|
||||||
|
if not book.isdigit():
|
||||||
|
# Error, slot not recognised, please use autocomplete menu
|
||||||
|
error = t(_p(
|
||||||
|
'cmd:schedule|create_booking|error:parse_slot',
|
||||||
|
"Time slot `{provided}` not recognised. "
|
||||||
|
"Please select a session to cancel from the autocomplete options."
|
||||||
|
))
|
||||||
|
lines = (True, error)
|
||||||
|
elif (slotid := int(book)) in schedule:
|
||||||
|
# Can't book because the slot is already booked
|
||||||
|
error = t(_p(
|
||||||
|
'cmd:schedule|create_booking|error:already_booked',
|
||||||
|
"You have already booked a scheduled session for {time}."
|
||||||
|
)).format(
|
||||||
|
time=discord.utils.format_dt(slotid_to_utc(slotid), style='t')
|
||||||
|
)
|
||||||
|
lines = (True, error)
|
||||||
|
elif (slotid_to_utc(slotid) - now).total_seconds() < 60:
|
||||||
|
# Can't book because it is running or about to start
|
||||||
|
error = t(_p(
|
||||||
|
'cmd:schedule|create_booking|error:too_soon',
|
||||||
|
"Cannot book session at {time} because it is running or starting soon!"
|
||||||
|
)).format(
|
||||||
|
time=discord.utils.format_dt(slotid_to_utc(slotid), style='t')
|
||||||
|
)
|
||||||
|
line = (True, error)
|
||||||
|
else:
|
||||||
|
# The slotid is valid and bookable
|
||||||
|
# Run the booking
|
||||||
|
try:
|
||||||
|
await self.create_booking(guildid, ctx.author.id)
|
||||||
|
ack = t(_p(
|
||||||
|
'cmd:schedule|create_booking|success',
|
||||||
|
"You have successfully scheduled a session at {time}."
|
||||||
|
)).format(
|
||||||
|
time=discord.utils.format_dt(slotid_to_utc(slotid), style='t')
|
||||||
|
)
|
||||||
|
line = (False, ack)
|
||||||
|
except UserInputError as e:
|
||||||
|
line = (True, e.msg)
|
||||||
|
lines.append(line)
|
||||||
|
|
||||||
|
if lines:
|
||||||
|
# Post lines
|
||||||
|
any_failed = False
|
||||||
|
text = []
|
||||||
|
|
||||||
|
for failed, msg in lines:
|
||||||
|
any_failed = any_failed or failed
|
||||||
|
emoji = self.bot.config.emojis.warning if failed else self.bot.config.emojis.tick
|
||||||
|
text.append(f"{emoji} {msg}")
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.brand_red() if any_failed else discord.Colour.brand_green(),
|
||||||
|
description='\n'.join(text)
|
||||||
|
)
|
||||||
|
await ctx.interaction.edit_original_response(embed=embed)
|
||||||
|
else:
|
||||||
|
# Post ScheduleUI
|
||||||
|
ui = ScheduleUI(self.bot, ctx.guild, ctx.author.id)
|
||||||
|
await ui.run(ctx.interaction)
|
||||||
|
await ui.wait()
|
||||||
|
|
||||||
|
async def _fetch_schedule(self, userid, **kwargs):
|
||||||
|
"""
|
||||||
|
Fetch the given user's schedule (i.e. booking map)
|
||||||
|
"""
|
||||||
|
nowid = time_to_slotid(utc_now())
|
||||||
|
|
||||||
|
booking_model = self.data.ScheduleSessionMember
|
||||||
|
bookings = await booking_model.fetch_where(
|
||||||
|
booking_model.slotid >= nowid,
|
||||||
|
userid=userid,
|
||||||
|
).order_by('slotid', ORDER.ASC)
|
||||||
|
|
||||||
|
return {
|
||||||
|
booking.slotid: booking for booking in bookings
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
@LionCog.placeholder_group
|
||||||
|
@cmds.hybrid_group('configre', with_app_command=False)
|
||||||
|
async def configure_group(self, ctx: LionContext):
|
||||||
|
"""
|
||||||
|
Substitute configure command group.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
config_params = {
|
||||||
|
'session_lobby': ScheduleSettings.SessionLobby,
|
||||||
|
'session_room': ScheduleSettings.SessionRoom,
|
||||||
|
'schedule_cost': ScheduleSettings.ScheduleCost,
|
||||||
|
'attendance_reward': ScheduleSettings.AttendanceReward,
|
||||||
|
'attendance_bonus': ScheduleSettings.AttendanceBonus,
|
||||||
|
'min_attendance': ScheduleSettings.MinAttendance,
|
||||||
|
'blacklist_role': ScheduleSettings.BlacklistRole,
|
||||||
|
'blacklist_after': ScheduleSettings.BlacklistAfter,
|
||||||
|
}
|
||||||
|
|
||||||
|
@configure_group.command(
|
||||||
|
name=_p('cmd:configure_schedule', "schedule"),
|
||||||
|
description=_p(
|
||||||
|
'cmd:configure_schedule|desc',
|
||||||
|
"Configure Scheduled Session system"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@appcmds.rename(
|
||||||
|
**{param: option._display_name for param, option in config_params.items()}
|
||||||
|
)
|
||||||
|
@appcmds.describe(
|
||||||
|
**{param: option._desc for param, option in config_params.items()}
|
||||||
|
)
|
||||||
|
@low_management_ward
|
||||||
|
async def configure_schedule_command(self, ctx: LionContext,
|
||||||
|
session_lobby: Optional[discord.TextChannel | discord.VoiceChannel] = None,
|
||||||
|
session_room: Optional[discord.VoiceChannel] = None,
|
||||||
|
schedule_cost: Optional[appcmds.Range[int, 0, MAX_COINS]] = None,
|
||||||
|
attendance_reward: Optional[appcmds.Range[int, 0, MAX_COINS]] = None,
|
||||||
|
attendance_bonus: Optional[appcmds.Range[int, 0, MAX_COINS]] = None,
|
||||||
|
min_attendance: Optional[appcmds.Range[int, 1, 60]] = None,
|
||||||
|
blacklist_role: Optional[discord.Role] = None,
|
||||||
|
blacklist_after: Optional[appcmds.Range[int, 1, 24]] = None
|
||||||
|
):
|
||||||
|
# Type Guards
|
||||||
|
if not ctx.guild:
|
||||||
|
return
|
||||||
|
if not ctx.interaction:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Map of parameter names to setting values
|
||||||
|
provided = {
|
||||||
|
'session_lobby': session_lobby,
|
||||||
|
'session_room': session_room,
|
||||||
|
'schedule_cost': schedule_cost,
|
||||||
|
'attendance_reward': attendance_reward,
|
||||||
|
'attendance_bonus': attendance_bonus,
|
||||||
|
'min_attendance': min_attendance,
|
||||||
|
'blacklist_role': blacklist_role,
|
||||||
|
'blacklist_after': blacklist_after,
|
||||||
|
}
|
||||||
|
modified = set(param for param, value in provided.items() if value is not None)
|
||||||
|
|
||||||
|
# Make a config instance
|
||||||
|
guild_data = await self.data.ScheduleGuild.fetch_or_create(ctx.guild.id)
|
||||||
|
config = ScheduleConfig(ctx.guild.id, guild_data)
|
||||||
|
|
||||||
|
if modified:
|
||||||
|
# Check provided values and build a list of write arguments
|
||||||
|
# Note that all settings are ModelSettings of ScheduleData.ScheduleGuild
|
||||||
|
lines = []
|
||||||
|
update_args = {}
|
||||||
|
settings = []
|
||||||
|
for param in modified:
|
||||||
|
# TODO: Add checks with setting._check_value
|
||||||
|
setting = self.config_params[param]
|
||||||
|
new_value = provided[param]
|
||||||
|
|
||||||
|
instance = config.get(setting.setting_id)
|
||||||
|
instance.value = new_value
|
||||||
|
settings.append(instance)
|
||||||
|
update_args[instance._column] = instance._data
|
||||||
|
lines.append(instance.update_message)
|
||||||
|
|
||||||
|
# Perform data update
|
||||||
|
await guild_data.update(**update_args)
|
||||||
|
# Dispatch setting updates to trigger hooks
|
||||||
|
for setting in settings:
|
||||||
|
setting.dispatch_update()
|
||||||
|
|
||||||
|
# Ack modified settings
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Launch config UI if needed
|
||||||
|
if ctx.channel.id not in ScheduleSettingUI._listening or not modified:
|
||||||
|
ui = ScheduleSettingUI(self.bot, ctx.guild.id, ctx.channel.id)
|
||||||
|
await ui.run(ctx.interaction)
|
||||||
|
await ui.wait()
|
||||||
3
src/modules/schedule/core/__init__.py
Normal file
3
src/modules/schedule/core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .session_member import SessionMember
|
||||||
|
from .session import ScheduledSession
|
||||||
|
from .timeslot import TimeSlot
|
||||||
544
src/modules/schedule/core/session.py
Normal file
544
src/modules/schedule/core/session.py
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
from typing import Optional
|
||||||
|
import datetime as dt
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from meta import LionBot
|
||||||
|
from utils.lib import utc_now
|
||||||
|
from utils.lib import MessageArgs
|
||||||
|
|
||||||
|
from .. import babel, logger
|
||||||
|
from ..data import ScheduleData as Data
|
||||||
|
from ..lib import slotid_to_utc
|
||||||
|
from ..settings import ScheduleSettings as Settings
|
||||||
|
from ..settings import ScheduleConfig
|
||||||
|
from ..ui.sessionui import SessionUI
|
||||||
|
|
||||||
|
from .session_member import SessionMember
|
||||||
|
|
||||||
|
_p = babel._p
|
||||||
|
|
||||||
|
my_room_permissions = discord.Permissions(
|
||||||
|
connect=True,
|
||||||
|
view_channel=True,
|
||||||
|
manage_roles=True,
|
||||||
|
manage_permissions=True
|
||||||
|
)
|
||||||
|
member_room_permissions = discord.PermissionOverwrite(
|
||||||
|
connect=True,
|
||||||
|
view_channel=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledSession:
|
||||||
|
"""
|
||||||
|
Guild-local context for a scheduled session timeslot.
|
||||||
|
|
||||||
|
Manages the status message and member list.
|
||||||
|
"""
|
||||||
|
update_interval = 60
|
||||||
|
max_update_interval = 10
|
||||||
|
|
||||||
|
# TODO: Slots
|
||||||
|
# NOTE: All methods MUST permit the guild or channels randomly vanishing
|
||||||
|
# NOTE: All methods MUST be robust, and not propagate exceptions
|
||||||
|
# TODO: Guild locale context
|
||||||
|
def __init__(self,
|
||||||
|
bot: LionBot,
|
||||||
|
data: Data.ScheduleSession, config_data: Data.ScheduleGuild,
|
||||||
|
session_channels: Settings.SessionChannels):
|
||||||
|
self.bot = bot
|
||||||
|
self.data = data
|
||||||
|
self.slotid = data.slotid
|
||||||
|
self.guildid = data.guildid
|
||||||
|
self.config = ScheduleConfig(self.guildid, config_data)
|
||||||
|
self.channels_setting = session_channels
|
||||||
|
|
||||||
|
self.starts_at = slotid_to_utc(self.slotid)
|
||||||
|
self.ends_at = slotid_to_utc(self.slotid + 3600)
|
||||||
|
|
||||||
|
# Whether to listen to clock events
|
||||||
|
# should be set externally after the clocks have been initially set
|
||||||
|
self.listening = False
|
||||||
|
# Whether the session has prepared the room and sent the first message
|
||||||
|
# Also set by open()
|
||||||
|
self.prepared = False
|
||||||
|
# Whether the session has set the room permissions
|
||||||
|
self.opened = False
|
||||||
|
# Whether this session has been cancelled. Always set externally
|
||||||
|
self.cancelled = False
|
||||||
|
|
||||||
|
self.members: dict[int, SessionMember] = {}
|
||||||
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
|
self.status_message = None
|
||||||
|
self._hook = None # Lobby webhook data
|
||||||
|
self._warned_hook = False
|
||||||
|
|
||||||
|
self._last_update = None
|
||||||
|
self._updater = None
|
||||||
|
self._status_task = None
|
||||||
|
|
||||||
|
# Setting shortcuts
|
||||||
|
@property
|
||||||
|
def room_channel(self) -> Optional[discord.VoiceChannel]:
|
||||||
|
return self.config.get(Settings.SessionRoom.setting_id).value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lobby_channel(self) -> Optional[discord.TextChannel]:
|
||||||
|
return self.config.get(Settings.SessionLobby.setting_id).value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bonus_reward(self) -> int:
|
||||||
|
return self.config.get(Settings.AttendanceBonus.setting_id).value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attended_reward(self) -> int:
|
||||||
|
return self.config.get(Settings.AttendanceReward.setting_id).value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def min_attendence(self) -> int:
|
||||||
|
return self.config.get(Settings.MinAttendance.setting_id).value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_attended(self) -> bool:
|
||||||
|
return all(member.total_clock >= self.min_attendence for member in self.members.values())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_run(self) -> bool:
|
||||||
|
"""
|
||||||
|
Returns True if this session exists and needs to run.
|
||||||
|
"""
|
||||||
|
return self.guild and self.members
|
||||||
|
|
||||||
|
@property
|
||||||
|
def messageid(self) -> Optional[int]:
|
||||||
|
return self.status_message.id if self.status_message else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def guild(self) -> Optional[discord.Guild]:
|
||||||
|
return self.bot.get_guild(self.guildid)
|
||||||
|
|
||||||
|
def validate_channel(self, channelid) -> bool:
|
||||||
|
channel = self.bot.get_channel(channelid)
|
||||||
|
if channel is not None:
|
||||||
|
channels = self.channels_setting.value
|
||||||
|
return (not channels) or (channel in channels) or (channel.category and (channel.category in channels))
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_lobby_hook(self) -> Optional[discord.Webhook]:
|
||||||
|
"""
|
||||||
|
Fetch or create the webhook in the scheduled session lobby
|
||||||
|
"""
|
||||||
|
channel = self.lobby_channel
|
||||||
|
if channel:
|
||||||
|
cid = channel.id
|
||||||
|
if self._hook and self._hook.channelid == cid:
|
||||||
|
hook = self._hook
|
||||||
|
else:
|
||||||
|
hook = self._hook = await self.bot.core.data.LionHook.fetch(cid)
|
||||||
|
if not hook:
|
||||||
|
# Attempt to create
|
||||||
|
try:
|
||||||
|
if channel.permissions_for(channel.guild.me).manage_webhooks:
|
||||||
|
avatar = self.bot.user.avatar
|
||||||
|
avatar_data = (await avatar.to_file()).fp.read() if avatar else None
|
||||||
|
webhook = await channel.create_webhook(
|
||||||
|
avatar=avatar_data,
|
||||||
|
name=f"{self.bot.user.name} Scheduled Sessions",
|
||||||
|
reason="Scheduled Session Lobby"
|
||||||
|
)
|
||||||
|
hook = await self.bot.core.data.LionHook.create(
|
||||||
|
channelid=cid,
|
||||||
|
token=webhook.token,
|
||||||
|
webhookid=webhook.id
|
||||||
|
)
|
||||||
|
elif channel.permissions_for(channel.guild.me).send_messages and not self._warned_hook:
|
||||||
|
t = self.bot.translator.t
|
||||||
|
self._warned_hook = True
|
||||||
|
await channel.send(
|
||||||
|
t(_p(
|
||||||
|
'session|error:lobby_webhook_perms',
|
||||||
|
"Insufficient permissions to create a webhook in this channel. "
|
||||||
|
"I require the `MANAGE_WEBHOOKS` permission."
|
||||||
|
))
|
||||||
|
)
|
||||||
|
except discord.HTTPException:
|
||||||
|
logger.warning(
|
||||||
|
"Unexpected Exception occurred while creating scheduled session lobby webhook.",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
if hook:
|
||||||
|
return hook.as_webhook(client=self.bot)
|
||||||
|
|
||||||
|
async def send(self, *args, wait=True, **kwargs):
|
||||||
|
lobby_hook = await self.get_lobby_hook()
|
||||||
|
if lobby_hook:
|
||||||
|
try:
|
||||||
|
return await lobby_hook.send(*args, wait=wait, **kwargs)
|
||||||
|
except discord.NotFound:
|
||||||
|
# Webhook was deleted under us
|
||||||
|
if self._hook is not None:
|
||||||
|
await self._hook.delete()
|
||||||
|
self._hook = None
|
||||||
|
except discord.HTTPException:
|
||||||
|
logger.warning(
|
||||||
|
f"Exception occurred sending to webhooks for scheduled session {self.data!r}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
async def prepare(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Execute prepare stage for this guild.
|
||||||
|
"""
|
||||||
|
async with self.lock:
|
||||||
|
await self.prepare_room()
|
||||||
|
await self.update_status(**kwargs)
|
||||||
|
self.prepared = True
|
||||||
|
|
||||||
|
async def prepare_room(self):
|
||||||
|
"""
|
||||||
|
Add overwrites allowing current members to connect.
|
||||||
|
"""
|
||||||
|
async with self.lock:
|
||||||
|
if not (members := list(self.members.values())):
|
||||||
|
return
|
||||||
|
if not (guild := self.guild):
|
||||||
|
return
|
||||||
|
if not (room := self.room_channel):
|
||||||
|
return
|
||||||
|
|
||||||
|
if room.permissions_for(guild.me) >= my_room_permissions:
|
||||||
|
# Add member overwrites
|
||||||
|
overwrites = room.overwrites
|
||||||
|
for member in members:
|
||||||
|
mobj = guild.get_member(member.userid)
|
||||||
|
if mobj:
|
||||||
|
overwrites[mobj] = discord.PermissionOverwrite(connect=True, view_channel=True)
|
||||||
|
try:
|
||||||
|
await room.edit(overwrites=overwrites)
|
||||||
|
except discord.HTTPException:
|
||||||
|
logger.warning(
|
||||||
|
f"Unexpected discord exception received while preparing schedule session room <cid: {room.id}> "
|
||||||
|
f"in guild <gid: {self.guildid}> for timeslot <sid: {self.slotid}>.",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"Prepared schedule session room <cid: {room.id}> "
|
||||||
|
f"in guild <gid: {self.guildid}> for timeslot <sid: {self.slotid}>.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
t = self.bot.translator.t
|
||||||
|
await self.send(
|
||||||
|
t(_p(
|
||||||
|
'session|prepare|error:room_permissions',
|
||||||
|
f"Could not prepare the configured session room {room} for the next scheduled session! "
|
||||||
|
"I require the `MANAGE_CHANNEL`, `MANAGE_ROLES`, `CONNECT` and `VIEW_CHANNEL` permissions."
|
||||||
|
)).format(room=room.mention)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def open_room(self):
|
||||||
|
"""
|
||||||
|
Remove overwrites for non-members.
|
||||||
|
"""
|
||||||
|
async with self.lock:
|
||||||
|
if not (members := list(self.members.values())):
|
||||||
|
return
|
||||||
|
if not (guild := self.guild):
|
||||||
|
return
|
||||||
|
if not (room := self.room_channel):
|
||||||
|
return
|
||||||
|
|
||||||
|
if room.permissions_for(guild.me) >= my_room_permissions:
|
||||||
|
# Replace the member overwrites
|
||||||
|
overwrites = {
|
||||||
|
target: overwrite for target, overwrite in room.overwrites.items()
|
||||||
|
if not isinstance(target, discord.Member)
|
||||||
|
}
|
||||||
|
for member in members:
|
||||||
|
mobj = guild.get_member(member.userid)
|
||||||
|
if mobj:
|
||||||
|
overwrites[mobj] = discord.PermissionOverwrite(connect=True, view_channel=True)
|
||||||
|
try:
|
||||||
|
await room.edit(overwrites=overwrites)
|
||||||
|
except discord.HTTPException:
|
||||||
|
logger.exception(
|
||||||
|
f"Unhandled discord exception received while opening schedule session room <cid: {room.id}> "
|
||||||
|
f"in guild <gid: {self.guildid}> for timeslot <sid: {self.slotid}>."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"Opened schedule session room <cid: {room.id}> "
|
||||||
|
f"in guild <gid: {self.guildid}> for timeslot <sid: {self.slotid}>.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
t = self.bot.translator.t
|
||||||
|
await self.send(
|
||||||
|
t(_p(
|
||||||
|
'session|open|error:room_permissions',
|
||||||
|
f"Could not set up the configured session room {room} for this scheduled session! "
|
||||||
|
"I require the `MANAGE_CHANNEL`, `MANAGE_ROLES`, `CONNECT` and `VIEW_CHANNEL` permissions."
|
||||||
|
)).format(room=room.mention)
|
||||||
|
)
|
||||||
|
self.prepared = True
|
||||||
|
self.opened = True
|
||||||
|
|
||||||
|
async def notify(self):
|
||||||
|
"""
|
||||||
|
Ghost ping members who have not yet attended.
|
||||||
|
"""
|
||||||
|
missing = [mid for mid, m in self.members.items() if m.total_clock == 0 and m.clock_start is None]
|
||||||
|
if missing:
|
||||||
|
ping = ''.join(f"<@{mid}>" for mid in missing)
|
||||||
|
message = await self.send(ping)
|
||||||
|
if message is not None:
|
||||||
|
asyncio.create_task(message.delete())
|
||||||
|
|
||||||
|
async def current_status(self) -> MessageArgs:
|
||||||
|
"""
|
||||||
|
Lobby status message args.
|
||||||
|
"""
|
||||||
|
t = self.bot.translator.t
|
||||||
|
now = utc_now()
|
||||||
|
|
||||||
|
view = SessionUI(self.bot, self.slotid, self.guildid)
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.orange(),
|
||||||
|
title=t(_p(
|
||||||
|
'session|status|title',
|
||||||
|
"Session {start} - {end}"
|
||||||
|
)).format(
|
||||||
|
start=discord.utils.format_dt(self.starts_at, 't'),
|
||||||
|
end=discord.utils.format_dt(self.ends_at, 't'),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
embed.timestamp = now
|
||||||
|
|
||||||
|
if self.cancelled:
|
||||||
|
embed.description = t(_p(
|
||||||
|
'session|status|desc:cancelled',
|
||||||
|
"I cancelled this scheduled session because I was unavailable. "
|
||||||
|
"All members who booked the session have been refunded."
|
||||||
|
))
|
||||||
|
view = None
|
||||||
|
elif not self.members:
|
||||||
|
embed.description = t(_p(
|
||||||
|
'session|status|desc:no_members',
|
||||||
|
"*No members scheduled this session.*"
|
||||||
|
))
|
||||||
|
elif now < self.starts_at:
|
||||||
|
# Preparation stage
|
||||||
|
embed.description = t(_p(
|
||||||
|
'session|status:preparing|desc:has_members',
|
||||||
|
"Starting {start}"
|
||||||
|
)).format(start=discord.utils.format_dt(self.starts_at, 'R'))
|
||||||
|
embed.add_field(
|
||||||
|
name=t(_p('session|status:preparing|field:members', "Members")),
|
||||||
|
value=', '.join(f"<@{m}>" for m in self.members)
|
||||||
|
)
|
||||||
|
elif now < self.starts_at + dt.timedelta(hours=1):
|
||||||
|
# Running status
|
||||||
|
embed.description = t(_p(
|
||||||
|
'session|status:running|desc:has_members',
|
||||||
|
"Finishing {start}"
|
||||||
|
)).format(start=discord.utils.format_dt(self.ends_at, 'R'))
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
present = []
|
||||||
|
min_attendence = self.min_attendence
|
||||||
|
for mid, member in self.members.items():
|
||||||
|
clock = int(member.total_clock)
|
||||||
|
if clock == 0 and member.clock_start is None:
|
||||||
|
memstr = f"<@{mid}>"
|
||||||
|
missing.append(memstr)
|
||||||
|
else:
|
||||||
|
memstr = "<@{mid}> **({M:02}:{S:02})**".format(
|
||||||
|
mid=mid,
|
||||||
|
M=int(clock // 60),
|
||||||
|
S=int(clock % 60)
|
||||||
|
)
|
||||||
|
present.append((memstr, clock, bool(member.clock_start)))
|
||||||
|
|
||||||
|
waiting_for = []
|
||||||
|
attending = []
|
||||||
|
attended = []
|
||||||
|
present.sort(key=lambda t: t[1], reverse=True)
|
||||||
|
for memstr, clock, clocking in present:
|
||||||
|
if clocking:
|
||||||
|
attending.append(memstr)
|
||||||
|
elif clock >= min_attendence:
|
||||||
|
attended.append(memstr)
|
||||||
|
else:
|
||||||
|
waiting_for.append(memstr)
|
||||||
|
waiting_for.extend(missing)
|
||||||
|
|
||||||
|
if waiting_for:
|
||||||
|
embed.add_field(
|
||||||
|
name=t(_p('session|status:running|field:waiting', "Waiting For")),
|
||||||
|
value='\n'.join(waiting_for),
|
||||||
|
inline=True
|
||||||
|
)
|
||||||
|
if attending:
|
||||||
|
embed.add_field(
|
||||||
|
name=t(_p('session|status:running|field:attending', "Attending")),
|
||||||
|
value='\n'.join(attending),
|
||||||
|
inline=True
|
||||||
|
)
|
||||||
|
if attended:
|
||||||
|
embed.add_field(
|
||||||
|
name=t(_p('session|status:running|field:attended', "Attended")),
|
||||||
|
value='\n'.join(attended),
|
||||||
|
inline=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Finished, show summary
|
||||||
|
attended = []
|
||||||
|
missed = []
|
||||||
|
min_attendence = self.min_attendence
|
||||||
|
for mid, member in self.members.items():
|
||||||
|
clock = int(member.total_clock)
|
||||||
|
memstr = "<@{mid}> **({M:02}:{S:02})**".format(
|
||||||
|
mid=mid,
|
||||||
|
M=int(clock // 60),
|
||||||
|
S=int(clock % 60)
|
||||||
|
)
|
||||||
|
if clock < min_attendence:
|
||||||
|
missed.append(memstr)
|
||||||
|
else:
|
||||||
|
attended.append(memstr)
|
||||||
|
|
||||||
|
if not missed:
|
||||||
|
# Everyone attended
|
||||||
|
embed.description = t(_p(
|
||||||
|
'session|status:finished|desc:everyone_att',
|
||||||
|
"Everyone attended the session! "
|
||||||
|
"All members were rewarded with {coin} **{reward} + {bonus}**!"
|
||||||
|
)).format(
|
||||||
|
coin=self.bot.config.emojis.coin,
|
||||||
|
reward=self.attended_reward,
|
||||||
|
bonus=self.bonus_reward
|
||||||
|
)
|
||||||
|
elif missed and attended:
|
||||||
|
# Mix of both
|
||||||
|
embed.description = t(_p(
|
||||||
|
'session|status:finished|desc:some_att',
|
||||||
|
"Everyone who attended was rewarded with {coin} **{reward}**! "
|
||||||
|
"Some members did not attend so everyone missed out on the bonus {coin} **{bonus}**.\n"
|
||||||
|
"**Members who missed their session have all future sessions cancelled without refund!*"
|
||||||
|
)).format(
|
||||||
|
coin=self.bot.config.emojis.coin,
|
||||||
|
reward=self.attended_reward,
|
||||||
|
bonus=self.bonus_reward
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No-one attended
|
||||||
|
embed.description = t(_p(
|
||||||
|
'session|status:finished|desc:some_att',
|
||||||
|
"No-one attended this session! No-one received rewards.\n"
|
||||||
|
"**Members who missed their session have all future sessions cancelled without refund!*"
|
||||||
|
))
|
||||||
|
|
||||||
|
if attended:
|
||||||
|
embed.add_field(
|
||||||
|
name=t(_p('session|status:finished|field:attended', "Attended")),
|
||||||
|
value='\n'.join(attended)
|
||||||
|
)
|
||||||
|
if missed:
|
||||||
|
embed.add_field(
|
||||||
|
name=t(_p('session|status:finished|field:missing', "Missing")),
|
||||||
|
value='\n'.join(missed)
|
||||||
|
)
|
||||||
|
view = None
|
||||||
|
|
||||||
|
if view is not None:
|
||||||
|
await view.reload()
|
||||||
|
args = MessageArgs(embed=embed, view=view)
|
||||||
|
return args
|
||||||
|
|
||||||
|
async def _update_status(self, save=True, resend=True):
|
||||||
|
"""
|
||||||
|
Send or update the lobby message.
|
||||||
|
"""
|
||||||
|
self._last_update = utc_now()
|
||||||
|
args = await self.current_status()
|
||||||
|
|
||||||
|
message = self.status_message
|
||||||
|
if message is None and self.data.messageid is not None:
|
||||||
|
lobby_hook = await self.get_lobby_hook()
|
||||||
|
if lobby_hook:
|
||||||
|
try:
|
||||||
|
message = await lobby_hook.fetch_message(self.data.messageid)
|
||||||
|
except discord.HTTPException:
|
||||||
|
message = None
|
||||||
|
|
||||||
|
repost = message is None
|
||||||
|
if not repost:
|
||||||
|
try:
|
||||||
|
await message.edit(**args.edit_args)
|
||||||
|
self.status_message = message
|
||||||
|
except discord.NotFound:
|
||||||
|
repost = True
|
||||||
|
self.status_message = None
|
||||||
|
except discord.HTTPException:
|
||||||
|
# Unexpected issue updating the message
|
||||||
|
logger.exception(
|
||||||
|
f"Exception occurred updating status for scheduled session {self.data!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if repost and resend and self.members:
|
||||||
|
message = await self.send(**args.send_args)
|
||||||
|
self.status_message = message
|
||||||
|
if save:
|
||||||
|
await self.data.update(messageid=message.id if message else None)
|
||||||
|
|
||||||
|
async def _update_status_soon(self, **kwargs):
|
||||||
|
try:
|
||||||
|
if self._last_update is not None:
|
||||||
|
next_update = self._last_update + dt.timedelta(seconds=self.max_update_interval)
|
||||||
|
await discord.utils.sleep_until(next_update)
|
||||||
|
task = asyncio.create_task(self._update_status(**kwargs))
|
||||||
|
await asyncio.shield(task)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_status_soon(self, **kwargs):
|
||||||
|
if self._status_task and not self._status_task.done():
|
||||||
|
self._status_task.cancel()
|
||||||
|
self._status_task = asyncio.create_task(self._update_status_soon(**kwargs))
|
||||||
|
|
||||||
|
async def update_status(self, **kwargs):
|
||||||
|
if self._status_task and not self._status_task.done():
|
||||||
|
self._status_task.cancel()
|
||||||
|
await self._update_status(**kwargs)
|
||||||
|
|
||||||
|
async def update_loop(self):
|
||||||
|
"""
|
||||||
|
Keep the lobby message up to date with a message per minute.
|
||||||
|
Takes into account external and manual updates.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self._last_update:
|
||||||
|
await discord.utils.sleep_until(self._last_update + dt.timedelta(seconds=self.update_interval))
|
||||||
|
|
||||||
|
while (now := utc_now()) <= self.ends_at:
|
||||||
|
await self.update_status()
|
||||||
|
while now < (next_update := (self._last_update + dt.timedelta(seconds=self.update_interval))):
|
||||||
|
await discord.utils.sleep_until(next_update)
|
||||||
|
now = utc_now()
|
||||||
|
await self.update_status()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.debug(
|
||||||
|
f"Cancelled scheduled session update loop <slotid: {self.slotid}> ,gid: {self.guildid}>"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Unknown exception encountered during session "
|
||||||
|
f"update loop <slotid: {self.slotid}> ,gid: {self.guildid}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def start_updating(self):
|
||||||
|
self._updater = asyncio.create_task(self.update_loop())
|
||||||
|
return self._updater
|
||||||
69
src/modules/schedule/core/session_member.py
Normal file
69
src/modules/schedule/core/session_member.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from collections import defaultdict
|
||||||
|
import datetime as dt
|
||||||
|
import asyncio
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from meta import LionBot
|
||||||
|
from utils.lib import utc_now
|
||||||
|
from core.lion_member import LionMember
|
||||||
|
|
||||||
|
from .. import babel, logger
|
||||||
|
from ..data import ScheduleData as Data
|
||||||
|
from ..lib import slotid_to_utc
|
||||||
|
|
||||||
|
_p = babel._p
|
||||||
|
|
||||||
|
|
||||||
|
class SessionMember:
|
||||||
|
"""
|
||||||
|
Member context for a scheduled session timeslot.
|
||||||
|
|
||||||
|
Intended to keep track of members for ongoing and upcoming sessions.
|
||||||
|
Primarily used to track clock time and set attended status.
|
||||||
|
"""
|
||||||
|
# TODO: slots
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
bot: LionBot, data: Data.ScheduleSessionMember,
|
||||||
|
lion: LionMember):
|
||||||
|
self.bot = bot
|
||||||
|
self.data = data
|
||||||
|
self.lion = lion
|
||||||
|
|
||||||
|
self.slotid = data.slotid
|
||||||
|
self.slot_start = slotid_to_utc(self.slotid)
|
||||||
|
self.slot_end = slotid_to_utc(self.slotid + 3600)
|
||||||
|
self.userid = data.userid
|
||||||
|
self.guildid = data.guildid
|
||||||
|
|
||||||
|
self.clock_start = None
|
||||||
|
self.clocked = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_clock(self):
|
||||||
|
clocked = self.clocked
|
||||||
|
if self.clock_start is not None:
|
||||||
|
end = min(utc_now(), self.slot_end)
|
||||||
|
clocked += (end - self.clock_start).total_seconds()
|
||||||
|
return clocked
|
||||||
|
|
||||||
|
def clock_on(self, at: dt.datetime):
|
||||||
|
"""
|
||||||
|
Mark this member as attending the scheduled session.
|
||||||
|
"""
|
||||||
|
if self.clock_start:
|
||||||
|
self.clock_off(at)
|
||||||
|
self.clock_start = max(self.slot_start, at)
|
||||||
|
|
||||||
|
def clock_off(self, at: dt.datetime):
|
||||||
|
"""
|
||||||
|
Mark this member as no longer attending.
|
||||||
|
"""
|
||||||
|
if not self.clock_start:
|
||||||
|
raise ValueError("Member clocking off while already off.")
|
||||||
|
end = min(at, self.slot_end)
|
||||||
|
self.clocked += (end - self.clock_start).total_seconds()
|
||||||
|
self.clock_start = None
|
||||||
529
src/modules/schedule/core/timeslot.py
Normal file
529
src/modules/schedule/core/timeslot.py
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
from collections import defaultdict
|
||||||
|
import datetime as dt
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from meta import LionBot
|
||||||
|
from meta.sharding import THIS_SHARD
|
||||||
|
from meta.logger import log_context, log_wrap
|
||||||
|
from utils.lib import utc_now
|
||||||
|
from core.lion_member import LionMember
|
||||||
|
from core.lion_guild import LionGuild
|
||||||
|
from tracking.voice.session import SessionState
|
||||||
|
from utils.data import as_duration, MEMBERS, TemporaryTable
|
||||||
|
from modules.economy.cog import Economy
|
||||||
|
from modules.economy.data import EconomyData, TransactionType
|
||||||
|
|
||||||
|
from .. import babel, logger
|
||||||
|
from ..data import ScheduleData as Data
|
||||||
|
from ..lib import slotid_to_utc, batchrun_per_second
|
||||||
|
from ..settings import ScheduleSettings
|
||||||
|
|
||||||
|
from .session import ScheduledSession
|
||||||
|
from .session_member import SessionMember
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..cog import ScheduleCog
|
||||||
|
|
||||||
|
_p = babel._p
|
||||||
|
|
||||||
|
|
||||||
|
class TimeSlot:
|
||||||
|
"""
|
||||||
|
Represents a single schedule session timeslot.
|
||||||
|
|
||||||
|
Maintains a cache of ScheduleSessions for event handling.
|
||||||
|
Responsible for the state of all scheduled sessions in this timeslot.
|
||||||
|
Provides methods for executing each stage of the time slot,
|
||||||
|
performing operations concurrently where possible.
|
||||||
|
"""
|
||||||
|
# TODO: Logging context
|
||||||
|
# TODO: Add per-shard jitter to improve ratelimit handling
|
||||||
|
|
||||||
|
def __init__(self, cog: 'ScheduleCog', slot_data: Data.ScheduleSlot):
|
||||||
|
self.cog = cog
|
||||||
|
self.bot: LionBot = cog.bot
|
||||||
|
self.data: Data = cog.data
|
||||||
|
self.slot_data = slot_data
|
||||||
|
self.slotid = slot_data.slotid
|
||||||
|
log_context.set(f"slotid: {self.slotid}")
|
||||||
|
|
||||||
|
self.prep_at = slotid_to_utc(self.slotid - 15*60)
|
||||||
|
self.start_at = slotid_to_utc(self.slotid)
|
||||||
|
self.end_at = slotid_to_utc(self.slotid + 3600)
|
||||||
|
|
||||||
|
self.preparing = asyncio.Event()
|
||||||
|
self.opening = asyncio.Event()
|
||||||
|
self.opened = asyncio.Event()
|
||||||
|
self.closing = asyncio.Event()
|
||||||
|
|
||||||
|
self.sessions: dict[int, ScheduledSession] = {} # guildid -> loaded ScheduledSession
|
||||||
|
self.run_task = None
|
||||||
|
self.loaded = False
|
||||||
|
|
||||||
|
@log_wrap(action="Fetch sessions")
|
||||||
|
async def fetch(self):
|
||||||
|
"""
|
||||||
|
Load all slot sessions from data. Must be executed before reading event based updates.
|
||||||
|
|
||||||
|
Does not take session lock because nothing external should read or modify before load.
|
||||||
|
"""
|
||||||
|
self.loaded = False
|
||||||
|
self.sessions.clear()
|
||||||
|
session_data = await self.data.ScheduleSession.fetch_where(
|
||||||
|
THIS_SHARD,
|
||||||
|
slotid=self.slotid,
|
||||||
|
closed_at=None,
|
||||||
|
)
|
||||||
|
sessions = await self.load_sessions(session_data)
|
||||||
|
self.sessions.update(sessions)
|
||||||
|
self.loaded = True
|
||||||
|
logger.info(
|
||||||
|
f"Timeslot <slotid: {self.slotid}> finished preloading {len(self.sessions)} guilds. Ready to open."
|
||||||
|
)
|
||||||
|
|
||||||
|
@log_wrap(action="Load sessions")
|
||||||
|
async def load_sessions(self, session_data) -> dict[int, ScheduledSession]:
|
||||||
|
"""
|
||||||
|
Load slot state for the provided GuildSchedule rows.
|
||||||
|
"""
|
||||||
|
if not session_data:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
guildids = [row.guildid for row in session_data]
|
||||||
|
|
||||||
|
# Bulk fetch guild config data
|
||||||
|
config_data = await self.data.ScheduleGuild.fetch_multiple(*guildids)
|
||||||
|
|
||||||
|
# Fetch channel data. This *should* hit cache if initialisation did its job
|
||||||
|
channel_settings = {guildid: await ScheduleSettings.SessionChannels.get(guildid) for guildid in guildids}
|
||||||
|
|
||||||
|
# Data fetch all member schedules with this slotid
|
||||||
|
members = await self.data.ScheduleSessionMember.fetch_where(
|
||||||
|
slotid=self.slotid,
|
||||||
|
guildid=guildids
|
||||||
|
)
|
||||||
|
# Bulk fetch lions
|
||||||
|
lions = await self.bot.core.lions.fetch_members(
|
||||||
|
*((m.guildid, m.userid) for m in members)
|
||||||
|
) if members else {}
|
||||||
|
|
||||||
|
# Partition member data
|
||||||
|
session_member_data = defaultdict(list)
|
||||||
|
for mem in members:
|
||||||
|
session_member_data[mem.guildid].append(mem)
|
||||||
|
|
||||||
|
# Create the session guilds and session members.
|
||||||
|
sessions = {}
|
||||||
|
for row in session_data:
|
||||||
|
session = ScheduledSession(self.bot, row, config_data[row.guildid], channel_settings[row.guildid])
|
||||||
|
smembers = {}
|
||||||
|
for memdata in session_member_data[row.guildid]:
|
||||||
|
smember = SessionMember(
|
||||||
|
self.bot, memdata, lions[memdata.guildid, memdata.userid]
|
||||||
|
)
|
||||||
|
smembers[memdata.userid] = smember
|
||||||
|
session.members = smembers
|
||||||
|
sessions[row.guildid] = session
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Timeslot <slotid: {self.slotid}> "
|
||||||
|
f"loaded guild data for {len(sessions)} guilds: {', '.join(map(str, guildids))}"
|
||||||
|
)
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
@log_wrap(action="Reset Clocks")
|
||||||
|
async def _reset_clocks(self, sessions: list[ScheduledSession]):
|
||||||
|
"""
|
||||||
|
Accurately set clocks (i.e. attendance time) for all tracked members in this time slot.
|
||||||
|
"""
|
||||||
|
now = utc_now()
|
||||||
|
tracker = self.bot.get_cog('VoiceTrackerCog')
|
||||||
|
tracking_lock = tracker.tracking_lock
|
||||||
|
session_locks = [session.lock for session in sessions]
|
||||||
|
|
||||||
|
# Take the tracking lock so that sessions are not started/finished while we reset the clock
|
||||||
|
try:
|
||||||
|
await tracking_lock.acquire()
|
||||||
|
[await lock.acquire() for lock in session_locks]
|
||||||
|
if now > self.start_at + dt.timedelta(minutes=5):
|
||||||
|
# Set initial clocks based on session data
|
||||||
|
# First request sessions intersection with the timeslot
|
||||||
|
memberids = [
|
||||||
|
(sm.data.guildid, sm.data.userid)
|
||||||
|
for sg in sessions for sm in sg.members.values()
|
||||||
|
]
|
||||||
|
session_map = {session.guildid: session for session in sessions}
|
||||||
|
model = tracker.data.VoiceSessions
|
||||||
|
if memberids:
|
||||||
|
voice_sessions = await model.table.select_where(
|
||||||
|
MEMBERS(*memberids),
|
||||||
|
model.start_time < self.end_at,
|
||||||
|
model.start_time + as_duration(model.duration) > self.start_at
|
||||||
|
).select(
|
||||||
|
'guildid', 'userid', 'start_time', 'channelid',
|
||||||
|
end_time=model.start_time + as_duration(model.duration)
|
||||||
|
).with_no_adapter()
|
||||||
|
else:
|
||||||
|
voice_sessions = []
|
||||||
|
|
||||||
|
# Intersect and aggregate sessions, accounting for session channels
|
||||||
|
clocks = defaultdict(int)
|
||||||
|
for vsession in voice_sessions:
|
||||||
|
if session_map[vsession['guildid']].validate_channel(vsession['channelid']):
|
||||||
|
start = max(vsession['start_time'], self.start_at)
|
||||||
|
end = min(vsession['end_time'], self.end_at)
|
||||||
|
clocks[(vsession['guildid'], vsession['userid'])] += (end - start).total_seconds()
|
||||||
|
|
||||||
|
# Now write clocks
|
||||||
|
for sg in sessions:
|
||||||
|
for sm in sg.members.values():
|
||||||
|
sg.clock = clocks[(sm.guildid, sm.userid)]
|
||||||
|
|
||||||
|
# Mark current attendance using current voice session
|
||||||
|
for session in sessions:
|
||||||
|
for smember in session.members.values():
|
||||||
|
voice_session = tracker.get_session(smember.data.guildid, smember.data.userid)
|
||||||
|
smember.clock_start = None
|
||||||
|
if voice_session is not None and voice_session.activity is SessionState.ONGOING:
|
||||||
|
if session.validate_channel(voice_session.data.channelid):
|
||||||
|
smember.clock_start = max(voice_session.data.start_time, self.start_at)
|
||||||
|
session.listening = True
|
||||||
|
finally:
|
||||||
|
tracking_lock.release()
|
||||||
|
[lock.release() for lock in session_locks]
|
||||||
|
|
||||||
|
@log_wrap(action="Prepare Sessions")
|
||||||
|
async def prepare(self, sessions: list[ScheduledSession]):
|
||||||
|
"""
|
||||||
|
Bulk prepare ScheduledSessions for the upcoming timeslot.
|
||||||
|
|
||||||
|
Preparing means sending the initial message and adding permissions for the next members.
|
||||||
|
This does not take the session lock for setting perms, because this is race-safe
|
||||||
|
(aside from potentially leaving extra permissions, which will be overwritten by `open`).
|
||||||
|
"""
|
||||||
|
logger.debug(f"Running prepare for time slot <slotid: {self.slotid}> with {len(sessions)} sessions.")
|
||||||
|
try:
|
||||||
|
coros = [session.prepare(save=False) for session in sessions if session.can_run]
|
||||||
|
await batchrun_per_second(coros, 5)
|
||||||
|
|
||||||
|
# Save messageids
|
||||||
|
tmptable = TemporaryTable(
|
||||||
|
'_gid', '_sid', '_mid',
|
||||||
|
types=('BIGINT', 'INTEGER', 'BIGINT')
|
||||||
|
)
|
||||||
|
tmptable.values = [
|
||||||
|
(sg.data.guildid, sg.data.slotid, sg.messageid)
|
||||||
|
for sg in sessions
|
||||||
|
if sg.messageid is not None
|
||||||
|
]
|
||||||
|
await Data.ScheduleSession.table.update_where(
|
||||||
|
guildid=tmptable['_gid'], slotid=tmptable['_sid']
|
||||||
|
).set(
|
||||||
|
messageid=tmptable['_mid']
|
||||||
|
).from_expr(tmptable)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
f"Unhandled exception while preparing timeslot <slotid: {self.slotid}>."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Prepared {len(sessions)} for scheduled session timeslot <slotid: {self.slotid}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
@log_wrap(action="Open Sessions")
|
||||||
|
async def open(self, sessions: list[ScheduledSession]):
|
||||||
|
"""
|
||||||
|
Bulk open guild sessions.
|
||||||
|
|
||||||
|
If session opens "late", uses voice session statistics to calculate clock times.
|
||||||
|
Otherwise, uses member's current sessions.
|
||||||
|
|
||||||
|
Due to the bulk channel update, this method may take up to 5 or 10 minutes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# List of sessions which have not been previously opened
|
||||||
|
# Used so that we only set channel permissions and notify and write opened once
|
||||||
|
fresh = [session for session in sessions if session.data.opened_at is None]
|
||||||
|
|
||||||
|
# Calculate the attended time so far, referencing voice session data if required
|
||||||
|
await self._reset_clocks(sessions)
|
||||||
|
|
||||||
|
# Bulk update lobby messages
|
||||||
|
message_tasks = [
|
||||||
|
asyncio.create_task(session.update_status(save=False))
|
||||||
|
for session in sessions
|
||||||
|
if session.lobby_channel is not None
|
||||||
|
]
|
||||||
|
notify_tasks = [
|
||||||
|
asyncio.create_task(session.notify())
|
||||||
|
for session in fresh
|
||||||
|
if session.lobby_channel is not None and session.data.opened_at is None
|
||||||
|
]
|
||||||
|
|
||||||
|
# Start lobby update loops
|
||||||
|
for session in sessions:
|
||||||
|
session.start_updating()
|
||||||
|
|
||||||
|
# Bulk run guild open to open session rooms
|
||||||
|
voice_coros = [
|
||||||
|
session.open_room()
|
||||||
|
for session in fresh
|
||||||
|
if session.room_channel is not None and session.data.opened_at is None
|
||||||
|
]
|
||||||
|
await batchrun_per_second(voice_coros, 5)
|
||||||
|
await asyncio.gather(*message_tasks)
|
||||||
|
await asyncio.gather(*notify_tasks)
|
||||||
|
|
||||||
|
# Write opened
|
||||||
|
if fresh:
|
||||||
|
now = utc_now()
|
||||||
|
tmptable = TemporaryTable(
|
||||||
|
'_gid', '_sid', '_mid', '_open',
|
||||||
|
types=('BIGINT', 'INTEGER', 'BIGINT', 'TIMESTAMPTZ')
|
||||||
|
)
|
||||||
|
tmptable.values = [
|
||||||
|
(sg.data.guildid, sg.data.slotid, sg.messageid, now)
|
||||||
|
for sg in fresh
|
||||||
|
]
|
||||||
|
await Data.ScheduleSession.table.update_where(
|
||||||
|
guildid=tmptable['_gid'], slotid=tmptable['_sid']
|
||||||
|
).set(
|
||||||
|
messageid=tmptable['_mid'],
|
||||||
|
opened_at=tmptable['_open']
|
||||||
|
).from_expr(tmptable)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
f"Unhandled exception while opening sessions for timeslot <slotid: {self.slotid}>."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Opened {len(sessions)} sessions for scheduled session timeslot <slotid: {self.slotid}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
@log_wrap(action="Close Sessions")
|
||||||
|
async def close(self, sessions: list[ScheduledSession], consequences=False):
|
||||||
|
"""
|
||||||
|
Close the session.
|
||||||
|
|
||||||
|
Responsible for saving the member attendance, performing economy updates,
|
||||||
|
closing the guild sessions, and if `consequences` is set,
|
||||||
|
cancels future member sessions and blacklists members as required.
|
||||||
|
Also performs the last lobby message update for this timeslot.
|
||||||
|
|
||||||
|
Does not modify session room channels (responsibility of the next open).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = await self.bot.db.get_connection()
|
||||||
|
async with conn.transaction():
|
||||||
|
# Calculate rewards
|
||||||
|
rewards = []
|
||||||
|
attendance = []
|
||||||
|
did_not_show = []
|
||||||
|
for session in sessions:
|
||||||
|
bonus = session.bonus_reward * session.all_attended
|
||||||
|
reward = session.attended_reward + bonus
|
||||||
|
required = session.min_attendence
|
||||||
|
for member in session.members.values():
|
||||||
|
guildid = member.guildid
|
||||||
|
userid = member.userid
|
||||||
|
attended = (member.total_clock >= required)
|
||||||
|
if attended:
|
||||||
|
rewards.append(
|
||||||
|
(TransactionType.SCHEDULE_REWARD,
|
||||||
|
guildid, self.bot.user.id,
|
||||||
|
0, userid,
|
||||||
|
reward, 0,
|
||||||
|
None)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
did_not_show.append((guildid, userid))
|
||||||
|
|
||||||
|
attendance.append(
|
||||||
|
(self.slotid, guildid, userid, attended, member.total_clock)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perform economy transactions
|
||||||
|
economy: Economy = self.bot.get_cog('Economy')
|
||||||
|
transactions = await economy.data.Transaction.execute_transactions(*rewards)
|
||||||
|
reward_ids = {
|
||||||
|
(t.guildid, t.to_account): t.transactionid
|
||||||
|
for t in transactions
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update lobby messages
|
||||||
|
message_tasks = [
|
||||||
|
asyncio.create_task(session.update_status(save=False))
|
||||||
|
for session in sessions
|
||||||
|
if session.lobby_channel is not None
|
||||||
|
]
|
||||||
|
await asyncio.gather(*message_tasks)
|
||||||
|
|
||||||
|
# Save attendance
|
||||||
|
if attendance:
|
||||||
|
att_table = TemporaryTable(
|
||||||
|
'_sid', '_gid', '_uid', '_att', '_clock', '_reward',
|
||||||
|
types=('INTEGER', 'BIGINT', 'BIGINT', 'BOOLEAN', 'INTEGER', 'INTEGER')
|
||||||
|
)
|
||||||
|
att_table.values = [
|
||||||
|
(sid, gid, uid, att, clock, reward_ids.get((gid, uid), None))
|
||||||
|
for sid, gid, uid, att, clock in attendance
|
||||||
|
]
|
||||||
|
await self.data.ScheduleSessionMember.table.update_where(
|
||||||
|
slotid=att_table['_sid'],
|
||||||
|
guildid=att_table['_gid'],
|
||||||
|
userid=att_table['_uid'],
|
||||||
|
).set(
|
||||||
|
attended=att_table['_att'],
|
||||||
|
clock=att_table['_clock'],
|
||||||
|
reward_transactionid=att_table['_reward']
|
||||||
|
).from_expr(att_table)
|
||||||
|
|
||||||
|
# Mark guild sessions as closed
|
||||||
|
if sessions:
|
||||||
|
await self.data.ScheduleSession.table.update_where(
|
||||||
|
slotid=self.slotid,
|
||||||
|
guildid=list(session.guildid for session in sessions)
|
||||||
|
).set(closed_at=utc_now())
|
||||||
|
|
||||||
|
if consequences and did_not_show:
|
||||||
|
# Trigger blacklist and cancel member bookings as needed
|
||||||
|
await self.cog.handle_noshow(*did_not_show)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
f"Unhandled exception while closing sessions for timeslot <slotid: {self.slotid}>."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Closed {len(sessions)} for scheduled session timeslot <slotid: {self.slotid}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def launch(self) -> asyncio.Task:
|
||||||
|
self.run_task = asyncio.create_task(self.run())
|
||||||
|
return self.run_task
|
||||||
|
|
||||||
|
@log_wrap(action="TimeSlot Run")
|
||||||
|
async def run(self):
|
||||||
|
"""
|
||||||
|
Execute each stage of the scheduled timeslot.
|
||||||
|
|
||||||
|
Skips preparation if the open time has passed.
|
||||||
|
"""
|
||||||
|
if not self.loaded:
|
||||||
|
raise ValueError("Attempting to run a Session before loading.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = utc_now()
|
||||||
|
if now < self.start_at:
|
||||||
|
await discord.utils.sleep_until(self.prep_at)
|
||||||
|
self.preparing.set()
|
||||||
|
await self.prepare(list(self.sessions.values()))
|
||||||
|
else:
|
||||||
|
await discord.utils.sleep_until(self.start_at)
|
||||||
|
self.preparing.set()
|
||||||
|
self.opening.set()
|
||||||
|
await self.open(list(self.sessions.values()))
|
||||||
|
self.opened.set()
|
||||||
|
await discord.utils.sleep_until(self.end_at)
|
||||||
|
self.closing.set()
|
||||||
|
await self.close(list(self.sessions.values()), consequences=True)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if self.closing.is_set():
|
||||||
|
state = 'closing'
|
||||||
|
elif self.opened.is_set():
|
||||||
|
state = 'opened'
|
||||||
|
elif self.opening.is_set():
|
||||||
|
state = 'opening'
|
||||||
|
elif self.preparing.is_set():
|
||||||
|
state = 'preparing'
|
||||||
|
logger.info(
|
||||||
|
f"Deactivating active time slot <slotid: {self.slotid}> "
|
||||||
|
f"with state '{state}'."
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
f"Unexpected exception occurred while running active time slot <slotid: {self.slotid}>."
|
||||||
|
)
|
||||||
|
|
||||||
|
@log_wrap(action="Slot Cleanup")
|
||||||
|
async def cleanup(self, sessions: list[ScheduledSession]):
|
||||||
|
"""
|
||||||
|
Cleanup after "missed" ScheduledSessions.
|
||||||
|
|
||||||
|
Missed sessions are unclosed sessions which are already past their closed time.
|
||||||
|
If the sessions were opened, they will be closed (with no consequences).
|
||||||
|
If the sessions were not opened, they will be cancelled (and the bookings refunded).
|
||||||
|
"""
|
||||||
|
now = utc_now()
|
||||||
|
if now < self.end_at:
|
||||||
|
raise ValueError("Attempting to cleanup sessions in current timeslot. Use close() or cancel() instead.")
|
||||||
|
|
||||||
|
# Split provided sessions into ignore/close/cancel
|
||||||
|
to_close = []
|
||||||
|
to_cancel = []
|
||||||
|
for session in sessions:
|
||||||
|
if session.slotid != self.slotid:
|
||||||
|
raise ValueError(f"Timeslot {self.slotid} attempting to cleanup session with slotid {session.slotid}")
|
||||||
|
|
||||||
|
if session.data.closed_at is not None:
|
||||||
|
# Already closed, ignore
|
||||||
|
pass
|
||||||
|
elif session.data.opened_at is not None:
|
||||||
|
# Session was opened, request close
|
||||||
|
to_close.append(session)
|
||||||
|
else:
|
||||||
|
# Session was never opened, request cancel
|
||||||
|
to_cancel.append(session)
|
||||||
|
|
||||||
|
# Handle close
|
||||||
|
if to_close:
|
||||||
|
await self._reset_clocks(to_close)
|
||||||
|
await self.close(to_close, consequences=False)
|
||||||
|
|
||||||
|
# Handle cancel
|
||||||
|
if to_cancel:
|
||||||
|
await self.cancel(to_cancel)
|
||||||
|
|
||||||
|
@log_wrap(action="Cancel TimeSlot")
|
||||||
|
async def cancel(self, sessions: list[ScheduledSession]):
|
||||||
|
"""
|
||||||
|
Cancel the provided sessions.
|
||||||
|
|
||||||
|
This involves refunding the booking transactions, deleting the booking rows,
|
||||||
|
and updating any messages that may have been posted.
|
||||||
|
"""
|
||||||
|
conn = await self.bot.db.get_connection()
|
||||||
|
async with conn.transaction():
|
||||||
|
# Collect booking rows
|
||||||
|
bookings = [member.data for session in sessions for member in session.members.values()]
|
||||||
|
|
||||||
|
if bookings:
|
||||||
|
# Refund booking transactions
|
||||||
|
economy: Economy = self.bot.get_cog('Economy')
|
||||||
|
maybe_tids = (r.book_transactionid for r in bookings)
|
||||||
|
tids = [tid for tid in maybe_tids if tid is not None]
|
||||||
|
await economy.data.Transaction.refund_transactions(*tids)
|
||||||
|
|
||||||
|
# Delete booking rows
|
||||||
|
await self.data.ScheduleSessionMember.table.delete_where(
|
||||||
|
MEMBERS(*((r.guildid, r.userid) for r in bookings)),
|
||||||
|
slotid=self.slotid,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trigger message update for existent messages
|
||||||
|
lobby_tasks = [
|
||||||
|
asyncio.create_task(session.update_status(save=False, resend=False))
|
||||||
|
for session in sessions
|
||||||
|
]
|
||||||
|
await asyncio.gather(*lobby_tasks)
|
||||||
|
|
||||||
|
# Mark sessions as closed
|
||||||
|
await self.data.ScheduleSession.table.update_where(
|
||||||
|
slotid=self.slotid,
|
||||||
|
guildid=[session.guildid for session in sessions]
|
||||||
|
).set(
|
||||||
|
closed_at=utc_now()
|
||||||
|
)
|
||||||
|
# TODO: Logging
|
||||||
156
src/modules/schedule/data.py
Normal file
156
src/modules/schedule/data.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
from data import Registry, RowModel, Table
|
||||||
|
from data.columns import Integer, Timestamp, String, Bool
|
||||||
|
from utils.data import MULTIVALUE_IN
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleData(Registry):
|
||||||
|
class ScheduleSlot(RowModel):
|
||||||
|
"""
|
||||||
|
Schema
|
||||||
|
------
|
||||||
|
"""
|
||||||
|
_tablename_ = 'schedule_slots'
|
||||||
|
|
||||||
|
slotid = Integer(primary=True)
|
||||||
|
created_at = Timestamp()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def fetch_multiple(cls, *slotids, create=True):
|
||||||
|
"""
|
||||||
|
Fetch multiple rows, applying cache where possible.
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
to_fetch = set()
|
||||||
|
for slotid in slotids:
|
||||||
|
row = cls._cache_.get(slotid, None)
|
||||||
|
if row is None:
|
||||||
|
to_fetch.add(slotid)
|
||||||
|
else:
|
||||||
|
results[slotid] = row
|
||||||
|
|
||||||
|
if to_fetch:
|
||||||
|
rows = await cls.fetch_where(slotid=list(to_fetch))
|
||||||
|
for row in rows:
|
||||||
|
results[row.slotid] = row
|
||||||
|
to_fetch.remove(row.slotid)
|
||||||
|
if to_fetch and create:
|
||||||
|
rows = await cls.table.insert_many(
|
||||||
|
('slotid',),
|
||||||
|
*((slotid,) for slotid in to_fetch)
|
||||||
|
).with_adapter(cls._make_rows)
|
||||||
|
for row in rows:
|
||||||
|
results[row.slotid] = row
|
||||||
|
return results
|
||||||
|
|
||||||
|
class ScheduleSessionMember(RowModel):
|
||||||
|
"""
|
||||||
|
Schema
|
||||||
|
------
|
||||||
|
"""
|
||||||
|
_tablename_ = 'schedule_session_members'
|
||||||
|
|
||||||
|
guildid = Integer(primary=True)
|
||||||
|
userid = Integer(primary=True)
|
||||||
|
slotid = Integer(primary=True)
|
||||||
|
booked_at = Timestamp()
|
||||||
|
attended = Bool()
|
||||||
|
clock = Integer()
|
||||||
|
book_transactionid = Integer()
|
||||||
|
reward_transactionid = Integer()
|
||||||
|
|
||||||
|
class ScheduleSession(RowModel):
|
||||||
|
"""
|
||||||
|
Schema
|
||||||
|
------
|
||||||
|
"""
|
||||||
|
_tablename_ = 'schedule_sessions'
|
||||||
|
|
||||||
|
guildid = Integer(primary=True)
|
||||||
|
slotid = Integer(primary=True)
|
||||||
|
opened_at = Timestamp()
|
||||||
|
closed_at = Timestamp()
|
||||||
|
messageid = Integer()
|
||||||
|
created_at = Timestamp()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def fetch_multiple(cls, *keys, create=True):
|
||||||
|
"""
|
||||||
|
Fetch multiple rows, applying cache where possible.
|
||||||
|
"""
|
||||||
|
# TODO: Factor this into a general multikey fetch many
|
||||||
|
results = {}
|
||||||
|
to_fetch = set()
|
||||||
|
for key in keys:
|
||||||
|
row = cls._cache_.get(key, None)
|
||||||
|
if row is None:
|
||||||
|
to_fetch.add(key)
|
||||||
|
else:
|
||||||
|
results[key] = row
|
||||||
|
|
||||||
|
if to_fetch:
|
||||||
|
condition = MULTIVALUE_IN(cls._key_, *to_fetch)
|
||||||
|
rows = await cls.fetch_where(condition)
|
||||||
|
for row in rows:
|
||||||
|
results[row._rowid_] = row
|
||||||
|
to_fetch.remove(row._rowid_)
|
||||||
|
if to_fetch and create:
|
||||||
|
rows = await cls.table.insert_many(
|
||||||
|
cls._key_,
|
||||||
|
*to_fetch
|
||||||
|
).with_adapter(cls._make_rows)
|
||||||
|
for row in rows:
|
||||||
|
results[row._rowid_] = row
|
||||||
|
return results
|
||||||
|
|
||||||
|
class ScheduleGuild(RowModel):
|
||||||
|
"""
|
||||||
|
Schema
|
||||||
|
------
|
||||||
|
"""
|
||||||
|
_tablename_ = 'schedule_guild_config'
|
||||||
|
_cache_ = {}
|
||||||
|
|
||||||
|
guildid = Integer(primary=True)
|
||||||
|
|
||||||
|
schedule_cost = Integer()
|
||||||
|
reward = Integer()
|
||||||
|
bonus_reward = Integer()
|
||||||
|
min_attendance = Integer()
|
||||||
|
lobby_channel = Integer()
|
||||||
|
room_channel = Integer()
|
||||||
|
blacklist_after = Integer()
|
||||||
|
blacklist_role = Integer()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def fetch_multiple(cls, *guildids, create=True):
|
||||||
|
"""
|
||||||
|
Fetch multiple rows, applying cache where possible.
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
to_fetch = set()
|
||||||
|
for guildid in guildids:
|
||||||
|
row = cls._cache_.get(guildid, None)
|
||||||
|
if row is None:
|
||||||
|
to_fetch.add(guildid)
|
||||||
|
else:
|
||||||
|
results[guildid] = row
|
||||||
|
|
||||||
|
if to_fetch:
|
||||||
|
rows = await cls.fetch_where(guildid=list(to_fetch))
|
||||||
|
for row in rows:
|
||||||
|
results[row.guildid] = row
|
||||||
|
to_fetch.remove(row.guildid)
|
||||||
|
if to_fetch and create:
|
||||||
|
rows = await cls.table.insert_many(
|
||||||
|
('guildid',),
|
||||||
|
*((guildid,) for guildid in to_fetch)
|
||||||
|
).with_adapter(cls._make_rows)
|
||||||
|
for row in rows:
|
||||||
|
results[row.guildid] = row
|
||||||
|
return results
|
||||||
|
|
||||||
|
"""
|
||||||
|
Schema
|
||||||
|
------
|
||||||
|
"""
|
||||||
|
schedule_channels = Table('schedule_channels')
|
||||||
41
src/modules/schedule/lib.py
Normal file
41
src/modules/schedule/lib.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import asyncio
|
||||||
|
import itertools
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from utils.ratelimits import Bucket
|
||||||
|
|
||||||
|
|
||||||
|
def time_to_slotid(time: dt.datetime) -> int:
|
||||||
|
"""
|
||||||
|
Return the slotid for the provided time.
|
||||||
|
"""
|
||||||
|
utctime = time.astimezone(dt.timezone.utc)
|
||||||
|
hour = utctime.replace(minute=0, second=0, microsecond=0)
|
||||||
|
return int(hour.timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def slotid_to_utc(sessionid: int) -> dt.datetime:
|
||||||
|
"""
|
||||||
|
Convert the given slotid (hour EPOCH) into a utc datetime.
|
||||||
|
"""
|
||||||
|
return dt.datetime.fromtimestamp(sessionid, tz=dt.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
async def batchrun_per_second(awaitables, batchsize):
|
||||||
|
"""
|
||||||
|
Run provided awaitables concurrently,
|
||||||
|
ensuring that no more than `batchsize` are running at once,
|
||||||
|
and that no more than `batchsize` are spawned per second.
|
||||||
|
|
||||||
|
Returns list of returned results or exceptions.
|
||||||
|
"""
|
||||||
|
bucket = Bucket(batchsize, 1)
|
||||||
|
sem = asyncio.Semaphore(batchsize)
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for awaitable in awaitables:
|
||||||
|
await asyncio.gather(bucket.wait(), sem.acquire())
|
||||||
|
bucket.request()
|
||||||
|
task = asyncio.create_task(awaitable)
|
||||||
|
task.add_done_callback(lambda fut: sem.release())
|
||||||
|
return await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
524
src/modules/schedule/settings.py
Normal file
524
src/modules/schedule/settings.py
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from settings import ModelData, ListData
|
||||||
|
from settings.groups import SettingGroup, ModelConfig, SettingDotDict
|
||||||
|
from settings.setting_types import (
|
||||||
|
ChannelSetting, IntegerSetting, ChannelListSetting, RoleSetting
|
||||||
|
)
|
||||||
|
from core.setting_types import CoinSetting
|
||||||
|
from meta import conf
|
||||||
|
from meta.errors import UserInputError
|
||||||
|
from meta.sharding import THIS_SHARD
|
||||||
|
from meta.logger import log_wrap
|
||||||
|
|
||||||
|
from babel.translator import ctx_translator
|
||||||
|
|
||||||
|
from . import babel, logger
|
||||||
|
from .data import ScheduleData
|
||||||
|
|
||||||
|
_p = babel._p
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleConfig(ModelConfig):
|
||||||
|
settings = SettingDotDict()
|
||||||
|
_model_settings = set()
|
||||||
|
model = ScheduleData.ScheduleGuild
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleSettings(SettingGroup):
|
||||||
|
@ScheduleConfig.register_model_setting
|
||||||
|
class SessionLobby(ModelData, ChannelSetting):
|
||||||
|
setting_id = 'session_lobby'
|
||||||
|
_event = 'guildset_session_lobby'
|
||||||
|
_set_cmd = 'configure schedule'
|
||||||
|
|
||||||
|
_display_name = _p('guildset:session_lobby', "session_lobby")
|
||||||
|
_desc = _p(
|
||||||
|
'guildset:session_lobby|desc',
|
||||||
|
"Channel to post scheduled session announcement and status to."
|
||||||
|
)
|
||||||
|
_long_desc = _p(
|
||||||
|
'guildset:session_lobby|long_desc',
|
||||||
|
"Channel in which to announce scheduled sessions and post their status. "
|
||||||
|
"I must have the `MANAGE_WEBHOOKS` permission in this channel.\n"
|
||||||
|
"**This must be configured in order for the scheduled session system to function.**"
|
||||||
|
)
|
||||||
|
_accepts = _p(
|
||||||
|
'guildset:session_lobby|accepts',
|
||||||
|
"Name or id of the session lobby channel."
|
||||||
|
)
|
||||||
|
|
||||||
|
_model = ScheduleData.ScheduleGuild
|
||||||
|
_column = ScheduleData.ScheduleGuild.lobby_channel.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_message(self):
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
if self.data:
|
||||||
|
resp = t(_p(
|
||||||
|
'guildset:session_lobby|set_response|set',
|
||||||
|
"Scheduled sessions will now be announced in {channel}"
|
||||||
|
)).format(channel=self.formatted)
|
||||||
|
else:
|
||||||
|
resp = t(_p(
|
||||||
|
'guildset:session_lobby|set_response|unset',
|
||||||
|
"The schedule session lobby has been unset. Shutting down scheduled session system."
|
||||||
|
))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, parent_id, data, **kwargs):
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
if data is None:
|
||||||
|
formatted = t(_p(
|
||||||
|
'guildset:session_lobby|formatted|unset',
|
||||||
|
"`Not Set` (The scheduled session system is disabled.)"
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
formatted = t(_p(
|
||||||
|
'guildset:session_lobby|formatted|set',
|
||||||
|
"<#{channelid}>"
|
||||||
|
)).format(channelid=data)
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
@ScheduleConfig.register_model_setting
|
||||||
|
class SessionRoom(ModelData, ChannelSetting):
|
||||||
|
setting_id = 'session_room'
|
||||||
|
_set_cmd = 'configure schedule'
|
||||||
|
|
||||||
|
_display_name = _p('guildset:session_room', "session_room")
|
||||||
|
_desc = _p(
|
||||||
|
'guildset:session_room|desc',
|
||||||
|
"Special voice channel open to scheduled session members."
|
||||||
|
)
|
||||||
|
_long_desc = _p(
|
||||||
|
'guildset:session_room|long_desc',
|
||||||
|
"If set, this voice channel serves as a dedicated room for scheduled session members. "
|
||||||
|
"During (and slightly before) each scheduled session, all members who have booked the session "
|
||||||
|
"will be given permission to join the voice channel (via permission overwrites). "
|
||||||
|
"I require the `MANAGE_CHANNEL`, `MANAGE_PERMISSIONS`, `CONNECT`, and `VIEW_CHANNEL` permissions "
|
||||||
|
"in this channel, and my highest role must be higher than all permission overwrites set in the channel."
|
||||||
|
)
|
||||||
|
_accepts = _p(
|
||||||
|
'guildset:session_room|accepts',
|
||||||
|
"Name or id of the session room voice channel."
|
||||||
|
)
|
||||||
|
channel_types = [discord.VoiceChannel]
|
||||||
|
|
||||||
|
_model = ScheduleData.ScheduleGuild
|
||||||
|
_column = ScheduleData.ScheduleGuild.room_channel.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_message(self):
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
if self.data:
|
||||||
|
resp = t(_p(
|
||||||
|
'guildset:session_room|set_response|set',
|
||||||
|
"Schedule session members will now be given access to {channel}"
|
||||||
|
)).format(channel=self.formatted)
|
||||||
|
else:
|
||||||
|
resp = t(_p(
|
||||||
|
'guildset:session_room|set_response|unset',
|
||||||
|
"The dedicated schedule session room has been removed."
|
||||||
|
))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
class SessionChannels(ListData, ChannelListSetting):
|
||||||
|
setting_id = 'session_channels'
|
||||||
|
|
||||||
|
_display_name = _p('guildset:session_channels', "session_channels")
|
||||||
|
_desc = _p(
|
||||||
|
'guildset:session_channels|desc',
|
||||||
|
"Voice channels in which to track activity for scheduled sessions."
|
||||||
|
)
|
||||||
|
_long_desc = _p(
|
||||||
|
'guildset:session_channels|long_desc',
|
||||||
|
"Only activity in these channels (and in `session_room` if set) will count towards "
|
||||||
|
"scheduled session attendance. If a category is selected, then all channels "
|
||||||
|
"under the category will also be included. "
|
||||||
|
"Activity tracking also respects the `untracked_voice_channels` setting."
|
||||||
|
)
|
||||||
|
_accepts = _p(
|
||||||
|
'guildset:session_channels|accepts',
|
||||||
|
"Comma separated list of session channel names or ids."
|
||||||
|
)
|
||||||
|
_default = None
|
||||||
|
|
||||||
|
_table_interface = ScheduleData.schedule_channels
|
||||||
|
_id_column = 'guildid'
|
||||||
|
_data_column = 'channelid'
|
||||||
|
_order_column = 'channelid'
|
||||||
|
|
||||||
|
_cache = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_message(self):
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
if self.data:
|
||||||
|
resp = t(_p(
|
||||||
|
'guildset:session_channels|set_response|set',
|
||||||
|
"Activity in the following sessions will now count towards scheduled session attendance: {channels}"
|
||||||
|
)).format(channels=self.formatted)
|
||||||
|
else:
|
||||||
|
resp = t(_p(
|
||||||
|
'guildset:session_channels|set_response|unset',
|
||||||
|
"Activity in all (tracked) voice channels will now count towards session attendance."
|
||||||
|
))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, parent_id, data, **kwargs):
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
if data is None:
|
||||||
|
formatted = t(_p(
|
||||||
|
'guildset:session_channels|formatted|unset',
|
||||||
|
"All Channels (excluding `untracked_channels`)"
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
formatted = super()._format_data(parent_id, data, **kwargs)
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@log_wrap(action='Cache Schedule Channels')
|
||||||
|
async def setup(cls, bot):
|
||||||
|
"""
|
||||||
|
Pre-load schedule channels for every guild on the current shard.
|
||||||
|
This includes guilds which the client cannot see.
|
||||||
|
"""
|
||||||
|
data = bot.db.registries['ScheduleData']
|
||||||
|
|
||||||
|
rows = await data.schedule_channels.select_where(THIS_SHARD)
|
||||||
|
new_cache = defaultdict(list)
|
||||||
|
count = 0
|
||||||
|
for row in rows:
|
||||||
|
new_cache[row['guildid']].append(row['channelid'])
|
||||||
|
count += 1
|
||||||
|
cls._cache.clear()
|
||||||
|
cls._cache.update(new_cache)
|
||||||
|
logger.info(f"Loaded {count} schedule session channels on this shard.")
|
||||||
|
|
||||||
|
@ScheduleConfig.register_model_setting
|
||||||
|
class ScheduleCost(ModelData, CoinSetting):
|
||||||
|
setting_id = 'schedule_cost'
|
||||||
|
_set_cmd = 'configure schedule'
|
||||||
|
|
||||||
|
_display_name = _p('guildset:schedule_cost', "schedule_cost")
|
||||||
|
_desc = _p(
|
||||||
|
'guildset:schedule_cost|desc',
|
||||||
|
"Booking cost for each scheduled session."
|
||||||
|
)
|
||||||
|
_long_desc = _p(
|
||||||
|
'guildset:schedule_cost|long_desc',
|
||||||
|
"Members will be charged this many LionCoins for each scheduled session they book."
|
||||||
|
)
|
||||||
|
_accepts = _p(
|
||||||
|
'guildset:schedule_cost|accepts',
|
||||||
|
"Price of each session booking (non-negative integer)."
|
||||||
|
)
|
||||||
|
_default = 100
|
||||||
|
|
||||||
|
_model = ScheduleData.ScheduleGuild
|
||||||
|
_column = ScheduleData.ScheduleGuild.schedule_cost.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_message(self) -> str:
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
resp = t(_p(
|
||||||
|
'guildset:schedule_cost|set_response',
|
||||||
|
"Schedule session bookings will now cost {coin} **{amount}** per timeslot."
|
||||||
|
)).format(
|
||||||
|
coin=conf.emojis.coin,
|
||||||
|
amount=self.value
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, parent_id, data, **kwargs):
|
||||||
|
if data is not None:
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
formatted = t(_p(
|
||||||
|
'guildset:schedule_cost|formatted',
|
||||||
|
"{coin}**{amount}** per booking."
|
||||||
|
)).format(coin=conf.emojis.coin, amount=data)
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
@ScheduleConfig.register_model_setting
|
||||||
|
class AttendanceReward(ModelData, CoinSetting):
|
||||||
|
setting_id = 'attendance_reward'
|
||||||
|
_set_cmd = 'configure schedule'
|
||||||
|
|
||||||
|
_display_name = _p('guildset:attendance_reward', "attendance_reward")
|
||||||
|
_desc = _p(
|
||||||
|
'guildset:attendance_reward|desc',
|
||||||
|
"Reward for attending a booked scheduled session."
|
||||||
|
)
|
||||||
|
_long_desc = _p(
|
||||||
|
'guildset:attendance_reward|long_desc',
|
||||||
|
"When a member successfully attends a scheduled session they booked, "
|
||||||
|
"they will be awarded this many LionCoins. "
|
||||||
|
"Should generally be more than the `schedule_cost` setting."
|
||||||
|
)
|
||||||
|
_accepts = _p(
|
||||||
|
'guildset:attendance_reward|accepts',
|
||||||
|
"Number of coins to reward session attendance."
|
||||||
|
)
|
||||||
|
_default = 200
|
||||||
|
|
||||||
|
_model = ScheduleData.ScheduleGuild
|
||||||
|
_column = ScheduleData.ScheduleGuild.reward.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_message(self) -> str:
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
resp = t(_p(
|
||||||
|
'guildset:attendance_reward|set_response',
|
||||||
|
"Members will be rewarded {coin}**{amount}** when they attend a scheduled session."
|
||||||
|
)).format(coin=conf.emojis.coin, amount=self.value)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, parent_id, data, **kwargs):
|
||||||
|
if data is not None:
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
formatted = t(_p(
|
||||||
|
'guildset:attendance_reward|formatted',
|
||||||
|
"{coin}**{amount}** upon attendance."
|
||||||
|
)).format(coin=conf.emojis.coin, amount=data)
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
@ScheduleConfig.register_model_setting
|
||||||
|
class AttendanceBonus(ModelData, CoinSetting):
|
||||||
|
setting_id = 'attendance_bonus'
|
||||||
|
_set_cmd = 'configure schedule'
|
||||||
|
|
||||||
|
_display_name = _p('guildset:attendance_bonus', "group_attendance_bonus")
|
||||||
|
_desc = _p(
|
||||||
|
'guildset:attendance_bonus|desc',
|
||||||
|
"Bonus reward given when all members attend a scheduled session."
|
||||||
|
)
|
||||||
|
_long_desc = _p(
|
||||||
|
'guildset:attendance_bonus|long_desc',
|
||||||
|
"When all members who have booked a session successfully attend the session, "
|
||||||
|
"they will be given this bonus in *addition* to the `attendance_reward`."
|
||||||
|
)
|
||||||
|
_accepts = _p(
|
||||||
|
'guildset:attendance_bonus|accepts',
|
||||||
|
"Bonus coins rewarded when everyone attends a session."
|
||||||
|
)
|
||||||
|
_default = 200
|
||||||
|
|
||||||
|
_model = ScheduleData.ScheduleGuild
|
||||||
|
_column = ScheduleData.ScheduleGuild.bonus_reward.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_message(self) -> str:
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
resp = t(_p(
|
||||||
|
'guildset:attendance_bonus|set_response',
|
||||||
|
"Session members will be rewarded an additional {coin}**{amount}** when everyone attends."
|
||||||
|
)).format(coin=conf.emojis.coin, amount=self.value)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, parent_id, data, **kwargs):
|
||||||
|
if data is not None:
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
formatted = t(_p(
|
||||||
|
'guildset:attendance_bonus|formatted',
|
||||||
|
"{coin}**{amount}** bonus when all booked members attend."
|
||||||
|
)).format(coin=conf.emojis.coin, amount=data)
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
@ScheduleConfig.register_model_setting
|
||||||
|
class MinAttendance(ModelData, IntegerSetting):
|
||||||
|
setting_id = 'min_attendance'
|
||||||
|
_set_cmd = 'configure schedule'
|
||||||
|
|
||||||
|
_display_name = _p('guildset:min_attendance', "min_attendance")
|
||||||
|
_desc = _p(
|
||||||
|
'guildset:min_attendance|desc',
|
||||||
|
"Minimum attendance before reward eligability."
|
||||||
|
)
|
||||||
|
_long_desc = _p(
|
||||||
|
'guildset:min_attendance|long_desc',
|
||||||
|
"Scheduled session members will need to attend the session for at least this number of minutes "
|
||||||
|
"before they are marked as having attended (and hence are rewarded)."
|
||||||
|
)
|
||||||
|
_accepts = _p(
|
||||||
|
'guildset:min_attendance|accepts',
|
||||||
|
"Number of minutes (1-60) before attendance is counted."
|
||||||
|
)
|
||||||
|
_default = 10
|
||||||
|
_min = 1
|
||||||
|
_max = 60
|
||||||
|
|
||||||
|
_model = ScheduleData.ScheduleGuild
|
||||||
|
_column = ScheduleData.ScheduleGuild.min_attendance.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_message(self) -> str:
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
resp = t(_p(
|
||||||
|
'guildset:min_attendance|set_response',
|
||||||
|
"Members will be rewarded after they have attended booked sessions for at least **`{amount}`** minutes."
|
||||||
|
)).format(amount=self.value)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, parent_id, data, **kwargs):
|
||||||
|
if data is not None:
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
formatted = t(_p(
|
||||||
|
'guildset:min_attendance|formatted',
|
||||||
|
"**`{amount}`** minutes"
|
||||||
|
)).format(amount=data)
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _parse_string(cls, parent_id, string: str, **kwargs):
|
||||||
|
if not string:
|
||||||
|
return None
|
||||||
|
|
||||||
|
string = string.strip('m ')
|
||||||
|
|
||||||
|
num = int(string) if string.isdigit() else None
|
||||||
|
try:
|
||||||
|
num = int(string)
|
||||||
|
except Exception:
|
||||||
|
num = None
|
||||||
|
|
||||||
|
if num is None or not 0 < num < 60:
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
error = t(_p(
|
||||||
|
'guildset:min_attendance|parse|error',
|
||||||
|
"Minimum attendance must be an integer number of minutes between `1` and `60`."
|
||||||
|
))
|
||||||
|
raise UserInputError(error)
|
||||||
|
|
||||||
|
@ScheduleConfig.register_model_setting
|
||||||
|
class BlacklistRole(ModelData, RoleSetting):
|
||||||
|
setting_id = 'schedule_blacklist_role'
|
||||||
|
_set_cmd = 'configure schedule'
|
||||||
|
_event = 'guildset_schedule_blacklist_role'
|
||||||
|
|
||||||
|
_display_name = _p('guildset:schedule_blacklist_role', "schedule_blacklist_role")
|
||||||
|
_desc = _p(
|
||||||
|
'guildset:schedule_blacklist_role|desc',
|
||||||
|
"Role which disables scheduled session booking."
|
||||||
|
)
|
||||||
|
_long_desc = _p(
|
||||||
|
'guildset:schedule_blacklist_role|long_desc',
|
||||||
|
"Members with this role will not be allowed to book scheduled sessions in this server. "
|
||||||
|
"If the role is manually added, all future scheduled sessions for the user are cancelled. "
|
||||||
|
"This provides a way to stop repeatedly unreliable members from blocking the group bonus for all members. "
|
||||||
|
"Alternatively, consider setting the booking cost (and reward) very high to provide "
|
||||||
|
"a strong disincentive for not attending a session."
|
||||||
|
)
|
||||||
|
_accepts = _p(
|
||||||
|
'guildset:schedule_blacklist_role|accepts',
|
||||||
|
"Blacklist role name or id."
|
||||||
|
)
|
||||||
|
|
||||||
|
_model = ScheduleData.ScheduleGuild
|
||||||
|
_column = ScheduleData.ScheduleGuild.blacklist_role.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_message(self):
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
if self.data:
|
||||||
|
resp = t(_p(
|
||||||
|
'guildset:schedule_blacklist_role|set_response|set',
|
||||||
|
"Members with {role} will be unable to book scheduled sessions."
|
||||||
|
)).format(role=self.formatted)
|
||||||
|
else:
|
||||||
|
resp = t(_p(
|
||||||
|
'guildset:schedule_blacklist_role|set_response|unset',
|
||||||
|
"The schedule blacklist role has been unset."
|
||||||
|
))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, parent_id, data, **kwargs):
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
if data is not None:
|
||||||
|
formatted = t(_p(
|
||||||
|
'guildset:schedule_blacklist_role|formatted|set',
|
||||||
|
"{role} members will not be able to book scheduled sessions."
|
||||||
|
)).format(role=f"<&{data}>")
|
||||||
|
else:
|
||||||
|
formatted = t(_p(
|
||||||
|
'guildset:schedule_blacklist_role|formatted|unset',
|
||||||
|
"Not Set"
|
||||||
|
))
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
@ScheduleConfig.register_model_setting
|
||||||
|
class BlacklistAfter(ModelData, IntegerSetting):
|
||||||
|
setting_id = 'schedule_blacklist_after'
|
||||||
|
_set_cmd = 'configure schedule'
|
||||||
|
|
||||||
|
_display_name = _p('guildset:schedule_blacklist_after', "schedule_blacklist_after")
|
||||||
|
_desc = _p(
|
||||||
|
'guildset:schedule_blacklist_after|desc',
|
||||||
|
"Number of missed sessions within 24h before blacklisting."
|
||||||
|
)
|
||||||
|
_long_desc = _p(
|
||||||
|
'guildset:schedule_blacklist_after|long_desc',
|
||||||
|
"Members who miss more than this number of booked sessions in a single 24 hour period "
|
||||||
|
"will be automatically given the `blacklist_role`. "
|
||||||
|
"Has no effect if the `blacklist_role` is not set or if I do not have sufficient permissions "
|
||||||
|
"to assign the blacklist role."
|
||||||
|
)
|
||||||
|
_accepts = _p(
|
||||||
|
'guildset:schedule_blacklist_after|accepts',
|
||||||
|
"A number of missed sessions (1-24) before blacklisting."
|
||||||
|
)
|
||||||
|
_default = None
|
||||||
|
_min = 1
|
||||||
|
_max = 24
|
||||||
|
|
||||||
|
_model = ScheduleData.ScheduleGuild
|
||||||
|
_column = ScheduleData.ScheduleGuild.blacklist_after.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_message(self) -> str:
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
if self.data:
|
||||||
|
resp = t(_p(
|
||||||
|
'guildset:schedule_blacklist_after|set_response|set',
|
||||||
|
"Members will be blacklisted after **`{amount}`** missed sessions within `24h`."
|
||||||
|
)).format(amount=self.data)
|
||||||
|
else:
|
||||||
|
resp = t(_p(
|
||||||
|
'guildset:schedule_blacklist_after|set_response|unset',
|
||||||
|
"Members will not be automatically blacklisted from booking scheduled sessions."
|
||||||
|
))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, parent_id, data, **kwargs):
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
if data is not None:
|
||||||
|
formatted = t(_p(
|
||||||
|
'guildset:schedule_blacklist_after|formatted|set',
|
||||||
|
"Blacklist after **`{amount}`** missed sessions within `24h`."
|
||||||
|
)).format(amount=data)
|
||||||
|
else:
|
||||||
|
formatted = t(_p(
|
||||||
|
'guildset:schedule_blacklist_after|formatted|unset',
|
||||||
|
"Do not automatically blacklist."
|
||||||
|
))
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _parse_string(cls, parent_id, string: str, **kwargs):
|
||||||
|
try:
|
||||||
|
return await super()._parse_string(parent_id, string, **kwargs)
|
||||||
|
except UserInputError:
|
||||||
|
t = ctx_translator.get().t
|
||||||
|
error = t(_p(
|
||||||
|
'guildset:schedule_blacklist_role|parse|error',
|
||||||
|
"Blacklist threshold must be a number between `1` and `24`."
|
||||||
|
))
|
||||||
|
raise UserInputError(error) from None
|
||||||
660
src/modules/schedule/ui/scheduleui.py
Normal file
660
src/modules/schedule/ui/scheduleui.py
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
import asyncio
|
||||||
|
import math
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord.ui.select import select, Select, SelectOption
|
||||||
|
from discord.ui.button import button, Button, ButtonStyle
|
||||||
|
|
||||||
|
from meta import conf, LionBot
|
||||||
|
from meta.errors import UserInputError
|
||||||
|
from data import ORDER
|
||||||
|
|
||||||
|
from utils.ui import MessageUI, Confirm
|
||||||
|
from utils.lib import MessageArgs, utc_now, tabulate, error_embed
|
||||||
|
from babel.translator import ctx_translator
|
||||||
|
|
||||||
|
from .. import babel, logger
|
||||||
|
from ..data import ScheduleData
|
||||||
|
from ..lib import slotid_to_utc, time_to_slotid
|
||||||
|
from ..settings import ScheduleConfig, ScheduleSettings
|
||||||
|
|
||||||
|
_p, _np = babel._p, babel._np
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..cog import ScheduleCog
|
||||||
|
from core.lion_member import LionMember
|
||||||
|
|
||||||
|
|
||||||
|
guide = _p(
|
||||||
|
'ui:schedule|about',
|
||||||
|
"Guide tips here TBD"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleUI(MessageUI):
|
||||||
|
"""
|
||||||
|
Primary UI pathway for viewing and modifying a member's schedule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bot: LionBot, guild: discord.Guild, callerid: int, **kwargs):
|
||||||
|
super().__init__(callerid=callerid, **kwargs)
|
||||||
|
self.bot = bot
|
||||||
|
self.cog: ScheduleCog = bot.get_cog('ScheduleCog')
|
||||||
|
self.guild = guild
|
||||||
|
|
||||||
|
self.guildid = guild.id
|
||||||
|
self.userid = callerid
|
||||||
|
self.lion: LionMember = None
|
||||||
|
|
||||||
|
# Data state
|
||||||
|
self.config: ScheduleConfig = None
|
||||||
|
self.blacklisted = False
|
||||||
|
self.schedule = {} # ordered map slotid -> ScheduleSessionMember
|
||||||
|
self.guilds = {} # Cache of guildid -> ScheduleGuild
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
self.recent_stats = (0, 0)
|
||||||
|
self.recent_avg = 0
|
||||||
|
self.all_stats = (0, 0)
|
||||||
|
self.all_avg = 0
|
||||||
|
|
||||||
|
self.streak = 0
|
||||||
|
|
||||||
|
# UI state
|
||||||
|
self.show_info = False
|
||||||
|
self.initial_load = False
|
||||||
|
self.now = utc_now()
|
||||||
|
self.nowid = time_to_slotid(self.now)
|
||||||
|
|
||||||
|
# ----- API -----
|
||||||
|
|
||||||
|
# ----- UI Components -----
|
||||||
|
# IDEA: History button?
|
||||||
|
|
||||||
|
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
|
||||||
|
async def quit_button(self, press: discord.Interaction, pressed: Button):
|
||||||
|
"""
|
||||||
|
Quit the schedule
|
||||||
|
"""
|
||||||
|
await press.response.defer()
|
||||||
|
await self.quit()
|
||||||
|
|
||||||
|
@button(emoji=conf.emojis.refresh, style=ButtonStyle.grey)
|
||||||
|
async def refresh_button(self, press: discord.Interaction, pressed: Button):
|
||||||
|
"""
|
||||||
|
Refresh the schedule
|
||||||
|
"""
|
||||||
|
await press.response.defer(thinking=True, ephemeral=True)
|
||||||
|
self.show_info = False
|
||||||
|
self.initial_load = False
|
||||||
|
await self.refresh(thinking=press)
|
||||||
|
|
||||||
|
@button(label='CLEAR_PLACEHOLDER', style=ButtonStyle.red)
|
||||||
|
async def clear_button(self, press: discord.Interaction, pressed: Button):
|
||||||
|
"""
|
||||||
|
Clear future sessions for this user.
|
||||||
|
"""
|
||||||
|
await press.response.defer(thinking=True, ephemeral=True)
|
||||||
|
t = self.bot.translator.t
|
||||||
|
|
||||||
|
# First update the schedule
|
||||||
|
now = self.now = utc_now()
|
||||||
|
nowid = self.nowid = time_to_slotid(now)
|
||||||
|
nextid = nowid + 3600
|
||||||
|
await self._load_schedule()
|
||||||
|
slotids = set(self.schedule.keys())
|
||||||
|
|
||||||
|
# Remove uncancellable slots
|
||||||
|
slotids.discard(nowid)
|
||||||
|
if (slotid_to_utc(nextid) - now).total_seconds() < 60:
|
||||||
|
slotids.discard(nextid)
|
||||||
|
if not slotids:
|
||||||
|
# Nothing to cancel
|
||||||
|
error = t(_p(
|
||||||
|
'ui:schedule|button:clear|error:nothing',
|
||||||
|
"No upcoming sessions to cancel! Your schedule is already clear."
|
||||||
|
))
|
||||||
|
embed = error_embed(error)
|
||||||
|
else:
|
||||||
|
# Do cancel
|
||||||
|
await self.cog.cancel_bookings(
|
||||||
|
*(
|
||||||
|
(slotid, self.schedule[slotid].guildid, self.userid)
|
||||||
|
for slotid in slotids
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ack = t(_p(
|
||||||
|
'ui:schedule|button:clear|success',
|
||||||
|
"Successfully cancelled and refunded your upcoming scheduled sessions."
|
||||||
|
))
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.brand_green(),
|
||||||
|
description=ack
|
||||||
|
)
|
||||||
|
await press.edit_original_response(embed=embed)
|
||||||
|
self.show_info = False
|
||||||
|
await self.refresh()
|
||||||
|
|
||||||
|
async def clear_button_refresh(self):
|
||||||
|
self.clear_button.label = self.bot.translator.t(_p(
|
||||||
|
'ui:schedule|button:clear|label',
|
||||||
|
"Clear Schedule"
|
||||||
|
))
|
||||||
|
if not self.schedule:
|
||||||
|
self.clear_button.disabled = True
|
||||||
|
|
||||||
|
@button(label='ABOUT_PLACEHOLDER', emoji=conf.emojis.question, style=ButtonStyle.grey)
|
||||||
|
async def about_button(self, press: discord.Interaction, pressed: Button):
|
||||||
|
"""
|
||||||
|
Replace message with the info page (temporarily).
|
||||||
|
"""
|
||||||
|
await press.response.defer(thinking=True, ephemeral=True)
|
||||||
|
self.show_info = not self.show_info
|
||||||
|
await self.refresh(thinking=press)
|
||||||
|
|
||||||
|
async def about_button_refresh(self):
|
||||||
|
self.about_button.label = self.bot.translator.t(_p(
|
||||||
|
'ui:schedule|button:about|label',
|
||||||
|
"About Schedule"
|
||||||
|
))
|
||||||
|
self.about_button.style = ButtonStyle.grey if self.show_info else ButtonStyle.blurple
|
||||||
|
|
||||||
|
@select(cls=Select, placeholder='BOOK_MENU_PLACEHOLDER')
|
||||||
|
async def booking_menu(self, selection: discord.Interaction, selected):
|
||||||
|
if selected.values[0] == 'None':
|
||||||
|
await selection.response.defer()
|
||||||
|
return
|
||||||
|
|
||||||
|
await selection.response.defer(thinking=True, ephemeral=True)
|
||||||
|
t = self.bot.translator.t
|
||||||
|
|
||||||
|
# Refresh the schedule
|
||||||
|
now = self.now = utc_now()
|
||||||
|
nowid = self.nowid = time_to_slotid(now)
|
||||||
|
nextid = nowid + 3600
|
||||||
|
next_soon = ((slotid_to_utc(nextid) - now).total_seconds() < 60)
|
||||||
|
await self._load_schedule()
|
||||||
|
|
||||||
|
# Check the requested slots
|
||||||
|
slotids = set(map(int, selected.values))
|
||||||
|
if nowid in slotids:
|
||||||
|
# Error with cannot book now
|
||||||
|
error = t(_p(
|
||||||
|
'ui:schedule|menu:booking|error:current_slot',
|
||||||
|
"You cannot schedule a currently running session!"
|
||||||
|
))
|
||||||
|
embed = error_embed(error)
|
||||||
|
elif (nextid in slotids) and next_soon:
|
||||||
|
# Error with too late
|
||||||
|
error = t(_p(
|
||||||
|
'ui:schedule|menu:booking|error:next_slot',
|
||||||
|
"Too late! You cannot schedule a session starting in the next minute."
|
||||||
|
))
|
||||||
|
embed = error_embed(error)
|
||||||
|
elif slotids.intersection(self.schedule.keys()):
|
||||||
|
# Error with already booked
|
||||||
|
error = t(_p(
|
||||||
|
'ui:schedule|menu:booking|error:already_booked',
|
||||||
|
"You have already booked one or more of the requested sessions!"
|
||||||
|
))
|
||||||
|
embed = error_embed(error)
|
||||||
|
else:
|
||||||
|
# Okay, slotids are valid.
|
||||||
|
# Check member balance is sufficient
|
||||||
|
await self.lion.data.refresh()
|
||||||
|
balance = self.lion.data.coins
|
||||||
|
requested = len(slotids)
|
||||||
|
required = requested * self.config.get(ScheduleSettings.ScheduleCost.setting_id).value
|
||||||
|
if required > balance:
|
||||||
|
error = t(_p(
|
||||||
|
'ui:schedule|menu:booking|error:insufficient_balance',
|
||||||
|
"Booking `{count}` scheduled sessions requires {coin}**{required}**, "
|
||||||
|
"but you only have {coin}**{balance}**!"
|
||||||
|
)).format(
|
||||||
|
count=requested, coin=conf.emojis.coin, required=required, balance=balance
|
||||||
|
)
|
||||||
|
embed = error_embed(error)
|
||||||
|
else:
|
||||||
|
# Everything checks out, run the booking
|
||||||
|
try:
|
||||||
|
await self.cog.create_booking(self.guildid, self.userid, *slotids)
|
||||||
|
timestrings = [
|
||||||
|
discord.utils.format_dt(slotid_to_utc(slotid), style='T')
|
||||||
|
for slotid in slotids
|
||||||
|
]
|
||||||
|
ack = t(_np(
|
||||||
|
'ui:schedule|menu:booking|success',
|
||||||
|
"Successfully booked your scheduled session at {times}.",
|
||||||
|
"Successfully booked the following scheduled sessions.\n{times}",
|
||||||
|
len(slotids)
|
||||||
|
)).format(
|
||||||
|
times='\n'.join(timestrings)
|
||||||
|
)
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.brand_green(),
|
||||||
|
description=ack
|
||||||
|
)
|
||||||
|
except UserInputError as e:
|
||||||
|
embed = error_embed(e.msg)
|
||||||
|
await selection.edit_original_response(embed=embed)
|
||||||
|
self.show_info = False
|
||||||
|
await self.refresh()
|
||||||
|
|
||||||
|
async def booking_menu_refresh(self):
|
||||||
|
t = self.bot.translator.t
|
||||||
|
menu = self.booking_menu
|
||||||
|
|
||||||
|
if self.blacklisted:
|
||||||
|
placeholder = t(_p(
|
||||||
|
'ui:schedule|menu:booking|placeholder:blacklisted',
|
||||||
|
"Book Sessions (Cannot book - Blacklisted)"
|
||||||
|
))
|
||||||
|
disabled = True
|
||||||
|
options = []
|
||||||
|
else:
|
||||||
|
disabled = False
|
||||||
|
placeholder = t(_p(
|
||||||
|
'ui:schedule|menu:booking|placeholder:regular',
|
||||||
|
"Book Sessions ({amount} LC)"
|
||||||
|
)).format(
|
||||||
|
coin=conf.emojis.coin,
|
||||||
|
amount=self.config.get(ScheduleSettings.ScheduleCost.setting_id).value
|
||||||
|
)
|
||||||
|
|
||||||
|
# Populate with choices
|
||||||
|
nowid = self.nowid
|
||||||
|
upcoming = [nowid + 3600 * i for i in range(1, 25)]
|
||||||
|
upcoming = [slotid for slotid in upcoming if slotid not in self.schedule]
|
||||||
|
options = self._format_slot_options(*upcoming)
|
||||||
|
|
||||||
|
menu.placeholder = placeholder
|
||||||
|
if options:
|
||||||
|
menu.options = options
|
||||||
|
menu.disabled = disabled
|
||||||
|
menu.max_values = len(menu.options)
|
||||||
|
else:
|
||||||
|
menu.options = [
|
||||||
|
SelectOption(label='None', value='None')
|
||||||
|
]
|
||||||
|
menu.disabled = True
|
||||||
|
menu.max_values = 1
|
||||||
|
|
||||||
|
def _format_slot_options(self, *slotids: int) -> list[SelectOption]:
|
||||||
|
"""
|
||||||
|
Format provided slotids into Select Options.
|
||||||
|
|
||||||
|
```
|
||||||
|
Today 23:00 (in <1 hour)
|
||||||
|
Tommorrow 01:00 (in 3 hours)
|
||||||
|
Today/Tomorrow {start} (in 1 hour)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
t = self.bot.translator.t
|
||||||
|
options = []
|
||||||
|
tz = self.lion.timezone
|
||||||
|
nowid = self.nowid
|
||||||
|
now = self.now.astimezone(tz)
|
||||||
|
|
||||||
|
slot_format = t(_p(
|
||||||
|
'ui:schedule|menu:slots|option|format',
|
||||||
|
"{day} {time} (in {until})"
|
||||||
|
))
|
||||||
|
today_name = t(_p(
|
||||||
|
'ui:schedule|menu:slots|option|day:today',
|
||||||
|
"Today"
|
||||||
|
))
|
||||||
|
tomorrow_name = t(_p(
|
||||||
|
'ui:schedule|menu:slots|option|day:tomorrow',
|
||||||
|
"Tomorrow"
|
||||||
|
))
|
||||||
|
|
||||||
|
for slotid in slotids:
|
||||||
|
slot_start = slotid_to_utc(slotid).astimezone(tz)
|
||||||
|
distance = int((slotid - nowid) // 3600)
|
||||||
|
until = self._format_until(distance)
|
||||||
|
day = today_name if (slot_start.day == now.day) else tomorrow_name
|
||||||
|
name = slot_format.format(
|
||||||
|
day=day,
|
||||||
|
time=slot_start.strftime('%H:%M'),
|
||||||
|
until=until
|
||||||
|
)
|
||||||
|
|
||||||
|
options.append(SelectOption(label=name, value=str(slotid)))
|
||||||
|
return options
|
||||||
|
|
||||||
|
def _format_until(self, distance):
|
||||||
|
t = self.bot.translator.t
|
||||||
|
return t(_np(
|
||||||
|
'ui:schedule|format_until',
|
||||||
|
"<1 hour",
|
||||||
|
"{number} hours",
|
||||||
|
distance
|
||||||
|
)).format(number=distance)
|
||||||
|
|
||||||
|
@select(cls=Select, placeholder='CANCEL_MENU_PLACEHOLDER')
|
||||||
|
async def cancel_menu(self, selection: discord.Interaction, selected):
|
||||||
|
"""
|
||||||
|
Cancel the selected slotids.
|
||||||
|
|
||||||
|
Refuses to cancel a slot if it is already running or within one minute of running.
|
||||||
|
"""
|
||||||
|
await selection.response.defer(thinking=True, ephemeral=True)
|
||||||
|
t = self.bot.translator.t
|
||||||
|
|
||||||
|
# Collect slotids that were requested
|
||||||
|
slotids = list(map(int, selected.values))
|
||||||
|
|
||||||
|
# Check for 'forbidden' slotids (possible due to long running UI)
|
||||||
|
now = utc_now()
|
||||||
|
nowid = time_to_slotid(now)
|
||||||
|
if nowid in slotids:
|
||||||
|
error = t(_p(
|
||||||
|
'ui:schedule|menu:cancel|error:current_slot',
|
||||||
|
"You cannot cancel a currently running *scheduled* session! Please attend it if possible."
|
||||||
|
))
|
||||||
|
embed = error_embed(error)
|
||||||
|
elif (nextid := nowid + 3600) in slotids and (slotid_to_utc(nextid) - now).total_seconds() < 60:
|
||||||
|
error = t(_p(
|
||||||
|
'ui:schedule|menu:cancel|error:too_late',
|
||||||
|
"Too late! You cannot cancel a scheduled session within a minute of it starting. "
|
||||||
|
"Please attend it if possible."
|
||||||
|
))
|
||||||
|
embed = error_embed(error)
|
||||||
|
else:
|
||||||
|
# Remaining slotids are now cancellable
|
||||||
|
# Although there is no guarantee the bookings are still valid.
|
||||||
|
# Request booking cancellation
|
||||||
|
booking_records = await self.cog.cancel_bookings(
|
||||||
|
*(
|
||||||
|
(slotid, self.schedule[slotid].guildid, self.userid)
|
||||||
|
for slotid in slotids
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not booking_records:
|
||||||
|
error = t(_p(
|
||||||
|
'ui:schedule|menu:cancel|error:already_cancelled',
|
||||||
|
"The selected bookings no longer exist! Nothing to cancel."
|
||||||
|
))
|
||||||
|
embed = error_embed(error)
|
||||||
|
else:
|
||||||
|
timestrings = [
|
||||||
|
discord.utils.format_dt(slotid_to_utc(record['slotid']), style='T')
|
||||||
|
for record in booking_records
|
||||||
|
]
|
||||||
|
ack = t(_np(
|
||||||
|
'ui:schedule|menu:cancel|success',
|
||||||
|
"Successfully cancelled and refunded your scheduled session booking for {times}.",
|
||||||
|
"Successfully cancelled and refunded your scheduled session bookings:\n{times}.",
|
||||||
|
len(booking_records)
|
||||||
|
)).format(
|
||||||
|
times='\n'.join(timestrings)
|
||||||
|
)
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.brand_green(),
|
||||||
|
description=ack
|
||||||
|
)
|
||||||
|
|
||||||
|
await selection.edit_original_response(embed=embed)
|
||||||
|
self.show_info = False
|
||||||
|
await self.refresh()
|
||||||
|
|
||||||
|
async def cancel_menu_refresh(self):
|
||||||
|
t = self.bot.translator.t
|
||||||
|
menu = self.cancel_menu
|
||||||
|
|
||||||
|
menu.placeholder = t(_p(
|
||||||
|
'ui:schedule|menu:cancel|placeholder',
|
||||||
|
"Cancel booked sessions"
|
||||||
|
))
|
||||||
|
can_cancel = set(self.schedule.keys())
|
||||||
|
can_cancel.discard(self.nowid)
|
||||||
|
menu.options = self._format_slot_options(*can_cancel)
|
||||||
|
menu.max_values = len(menu.options)
|
||||||
|
|
||||||
|
# ----- UI Flow -----
|
||||||
|
async def make_message(self) -> MessageArgs:
|
||||||
|
t = self.bot.translator.t
|
||||||
|
# Show booking cost somewhere (in booking menu)
|
||||||
|
# Show info automatically if member has never booked a session
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.orange(),
|
||||||
|
)
|
||||||
|
member = self.lion.member
|
||||||
|
embed.set_author(
|
||||||
|
name=t(_p(
|
||||||
|
'ui:schedule|embed|author',
|
||||||
|
"Your Scheduled Sessions and Past Statistics"
|
||||||
|
)).format(name=member.display_name if member else self.lion.luser.data.name),
|
||||||
|
icon_url=self.lion.member.avatar
|
||||||
|
)
|
||||||
|
if self.show_info:
|
||||||
|
# Info message
|
||||||
|
embed.description = t(guide)
|
||||||
|
else:
|
||||||
|
# Statistics table
|
||||||
|
stats_fields = {}
|
||||||
|
recent_key = t(_p(
|
||||||
|
'ui:schedule|embed|field:stats|field:recent',
|
||||||
|
"Recent"
|
||||||
|
))
|
||||||
|
recent_value = self._format_stats(*self.recent_stats, self.recent_avg)
|
||||||
|
stats_fields[recent_key] = recent_value
|
||||||
|
if self.recent_stats[1] == 100:
|
||||||
|
alltime_key = t(_p(
|
||||||
|
'ui:schedule|embed|field:stats|field:alltime',
|
||||||
|
"All Time"
|
||||||
|
))
|
||||||
|
alltime_value = self._format_stats(*self.all_stats, self.all_avg)
|
||||||
|
stats_fields[alltime_key] = alltime_value
|
||||||
|
streak_key = t(_p(
|
||||||
|
'ui:schedule|embed|field:stats|field:streak',
|
||||||
|
"Streak"
|
||||||
|
))
|
||||||
|
if self.streak:
|
||||||
|
streak_value = t(_np(
|
||||||
|
'ui:schedule|embed|field:stats|field:streak|value:zero',
|
||||||
|
"One session attended! Keep it up!",
|
||||||
|
"**{streak}** sessions attended in a row! Good job!",
|
||||||
|
self.streak,
|
||||||
|
)).format(streak=self.streak)
|
||||||
|
else:
|
||||||
|
streak_value = t(_p(
|
||||||
|
'ui:schedule|embed|field:stats|field:streak|value:positive',
|
||||||
|
"No streak yet!"
|
||||||
|
))
|
||||||
|
stats_fields[streak_key] = streak_value
|
||||||
|
|
||||||
|
table = tabulate(*stats_fields.items())
|
||||||
|
embed.add_field(
|
||||||
|
name=t(_p(
|
||||||
|
'ui:schedule|embed|field:stats|name',
|
||||||
|
"Session Statistics"
|
||||||
|
)),
|
||||||
|
value='\n'.join(table),
|
||||||
|
inline=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upcoming sessions
|
||||||
|
upcoming = list(self.schedule.values())
|
||||||
|
guildids = set(row.guildid for row in upcoming)
|
||||||
|
show_guild = (len(guildids) > 1) or (self.guildid not in guildids)
|
||||||
|
|
||||||
|
# Split lists in about half if they are too long for one field.
|
||||||
|
split = math.ceil(len(upcoming) / 2) if len(upcoming) >= 12 else 12
|
||||||
|
block1 = upcoming[:split]
|
||||||
|
block2 = upcoming[split:]
|
||||||
|
|
||||||
|
embed.add_field(
|
||||||
|
name=t(_p(
|
||||||
|
'ui:schedule|embed|field:upcoming|name',
|
||||||
|
"Upcoming Sessions"
|
||||||
|
)),
|
||||||
|
value=self._format_bookings(block1, show_guild) if block1 else t(_p(
|
||||||
|
'ui:schedule|embed|field:upcoming|value:empty',
|
||||||
|
"No sessions scheduled yet!"
|
||||||
|
))
|
||||||
|
)
|
||||||
|
if block2:
|
||||||
|
embed.add_field(
|
||||||
|
name='-'*5,
|
||||||
|
value=self._format_bookings(block2, show_guild)
|
||||||
|
)
|
||||||
|
return MessageArgs(embed=embed)
|
||||||
|
|
||||||
|
def _format_stats(self, attended, total, average):
|
||||||
|
t = self.bot.translator.t
|
||||||
|
return t(_p(
|
||||||
|
'ui:schedule|embed|stats_format',
|
||||||
|
"**{attended}** attended out of **{total}** booked.\r\n"
|
||||||
|
"**{percent}%** attendance rate.\r\n"
|
||||||
|
"**{average}** average attendance time."
|
||||||
|
)).format(
|
||||||
|
attended=attended,
|
||||||
|
total=total,
|
||||||
|
percent=math.ceil(attended/total * 100) if total else 0,
|
||||||
|
average=f"{int(average // 60)}:{average % 60:02}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _format_bookings(self, bookings, show_guild=False):
|
||||||
|
t = self.bot.translator.t
|
||||||
|
short_format = t(_p(
|
||||||
|
'ui:schedule|booking_format:short',
|
||||||
|
"`in {until}` | {start} - {end}"
|
||||||
|
))
|
||||||
|
long_format = t(_p(
|
||||||
|
'ui:schedule|booking_format:long',
|
||||||
|
"> `in {until}` | {start} - {end}"
|
||||||
|
))
|
||||||
|
items = []
|
||||||
|
format = long_format if show_guild else short_format
|
||||||
|
last_guildid = None
|
||||||
|
for booking in bookings:
|
||||||
|
guildid = booking.guildid
|
||||||
|
data = self.guilds[guildid]
|
||||||
|
|
||||||
|
if last_guildid != guildid:
|
||||||
|
channel = f"<#{data.lobby_channel}>"
|
||||||
|
items.append(channel)
|
||||||
|
last_guildid = guildid
|
||||||
|
|
||||||
|
start = slotid_to_utc(booking.slotid)
|
||||||
|
end = slotid_to_utc(booking.slotid + 3600)
|
||||||
|
item = format.format(
|
||||||
|
until=self._format_until(int((booking.slotid - self.nowid) // 3600)),
|
||||||
|
start=discord.utils.format_dt(start, style='t'),
|
||||||
|
end=discord.utils.format_dt(end, style='t'),
|
||||||
|
)
|
||||||
|
items.append(item)
|
||||||
|
return '\n'.join(items)
|
||||||
|
|
||||||
|
async def refresh_layout(self):
|
||||||
|
# Don't show cancel menu or clear button if the schedule is empty
|
||||||
|
await asyncio.gather(
|
||||||
|
self.clear_button_refresh(),
|
||||||
|
self.about_button_refresh(),
|
||||||
|
self.booking_menu_refresh(),
|
||||||
|
self.cancel_menu_refresh(),
|
||||||
|
)
|
||||||
|
if self.schedule and self.cancel_menu.options:
|
||||||
|
self.set_layout(
|
||||||
|
(self.about_button, self.refresh_button, self.clear_button, self.quit_button),
|
||||||
|
(self.booking_menu,),
|
||||||
|
(self.cancel_menu,),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.set_layout(
|
||||||
|
(self.about_button, self.refresh_button, self.quit_button),
|
||||||
|
(self.booking_menu,)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def reload(self):
|
||||||
|
now = utc_now()
|
||||||
|
nowid = time_to_slotid(now)
|
||||||
|
self.initial_load = self.initial_load and (nowid == self.nowid)
|
||||||
|
self.now = now
|
||||||
|
self.nowid = nowid
|
||||||
|
|
||||||
|
if not self.initial_load:
|
||||||
|
await self._load_member()
|
||||||
|
await self._load_statistics()
|
||||||
|
self.show_info = not self.recent_stats[1]
|
||||||
|
self.initial_load = True
|
||||||
|
|
||||||
|
await self._load_schedule()
|
||||||
|
|
||||||
|
member = self.guild.get_member(self.userid)
|
||||||
|
blacklist_role = self.config.get(ScheduleSettings.BlacklistRole.setting_id).value
|
||||||
|
self.blacklisted = member and blacklist_role and (blacklist_role in member.roles)
|
||||||
|
|
||||||
|
async def _load_schedule(self):
|
||||||
|
"""
|
||||||
|
Load current member schedule and update guild config cache.
|
||||||
|
"""
|
||||||
|
nowid = self.nowid
|
||||||
|
|
||||||
|
booking_model = self.cog.data.ScheduleSessionMember
|
||||||
|
bookings = await booking_model.fetch_where(
|
||||||
|
booking_model.slotid >= nowid,
|
||||||
|
userid=self.userid,
|
||||||
|
).order_by('slotid', ORDER.ASC)
|
||||||
|
guildids = list(set(booking.guildid for booking in bookings))
|
||||||
|
guilds = await self.cog.data.ScheduleGuild.fetch_multiple(*guildids)
|
||||||
|
self.guilds.update(guilds)
|
||||||
|
self.schedule = {
|
||||||
|
booking.slotid: booking for booking in bookings
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _load_member(self):
|
||||||
|
self.lion = await self.bot.core.lions.fetch_member(self.guildid, self.userid)
|
||||||
|
await self.lion.data.refresh()
|
||||||
|
|
||||||
|
guild_data = await self.cog.data.ScheduleGuild.fetch_or_create(self.guildid)
|
||||||
|
self.guilds[self.guildid] = guild_data
|
||||||
|
self.config = ScheduleConfig(self.guildid, guild_data)
|
||||||
|
|
||||||
|
async def _load_statistics(self):
|
||||||
|
now = utc_now()
|
||||||
|
nowid = time_to_slotid(now)
|
||||||
|
|
||||||
|
# Fetch (up to 100) most recent bookings
|
||||||
|
booking_model = self.cog.data.ScheduleSessionMember
|
||||||
|
recent = await booking_model.fetch_where(
|
||||||
|
booking_model.slotid < nowid,
|
||||||
|
userid=self.userid,
|
||||||
|
).order_by('slotid', ORDER.DESC).limit(100)
|
||||||
|
|
||||||
|
# Calculate recent stats
|
||||||
|
recent_total_clock = 0
|
||||||
|
recent_att = 0
|
||||||
|
recent_count = len(recent)
|
||||||
|
streak = 0
|
||||||
|
streak_broken = False
|
||||||
|
for row in recent:
|
||||||
|
recent_total_clock += row.clock
|
||||||
|
if row.attended:
|
||||||
|
recent_att += 1
|
||||||
|
if not streak_broken:
|
||||||
|
streak += 1
|
||||||
|
else:
|
||||||
|
streak_broken = True
|
||||||
|
|
||||||
|
self.recent_stats = (recent_att, recent_count)
|
||||||
|
self.recent_avg = int(recent_total_clock // (60 * recent_count)) if recent_count else 0
|
||||||
|
self.streak = streak
|
||||||
|
|
||||||
|
# Calculate all-time stats
|
||||||
|
if recent_count == 100:
|
||||||
|
record = await booking_model.table.select_one_where(
|
||||||
|
booking_model.slotid < nowid,
|
||||||
|
userid=self.userid,
|
||||||
|
).select(
|
||||||
|
_booked='COUNT(*)',
|
||||||
|
_attended='COUNT(*) FILTER (WHERE attended)',
|
||||||
|
_clocked='SUM(COALESCE(clock, 0))'
|
||||||
|
).with_no_adapter()
|
||||||
|
self.all_stats = (record['_attended'], record['_booked'])
|
||||||
|
self.all_avg = record['_clocked'] // (60 * record['_booked'])
|
||||||
|
else:
|
||||||
|
self.all_stats = self.recent_stats
|
||||||
|
self.all_avg = self.recent_avg
|
||||||
180
src/modules/schedule/ui/sessionui.py
Normal file
180
src/modules/schedule/ui/sessionui.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord.ui.button import button, Button, ButtonStyle
|
||||||
|
|
||||||
|
from meta import conf, LionBot
|
||||||
|
from meta.errors import UserInputError
|
||||||
|
from utils.lib import utc_now
|
||||||
|
from utils.ui import LeoUI
|
||||||
|
from babel.translator import ctx_locale
|
||||||
|
|
||||||
|
from .. import babel, logger
|
||||||
|
from ..lib import slotid_to_utc, time_to_slotid
|
||||||
|
|
||||||
|
from .scheduleui import ScheduleUI
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..cog import ScheduleCog
|
||||||
|
|
||||||
|
_p = babel._p
|
||||||
|
|
||||||
|
|
||||||
|
class SessionUI(LeoUI):
|
||||||
|
# Maybe add a button to check channel permissions
|
||||||
|
# And make the session update the channel if it is missing permissions
|
||||||
|
|
||||||
|
def __init__(self, bot: LionBot, slotid: int, guildid: int, **kwargs):
|
||||||
|
kwargs.setdefault('timeout', 3600)
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.bot = bot
|
||||||
|
self.cog: 'ScheduleCog' = bot.get_cog('ScheduleCog')
|
||||||
|
self.slotid = slotid
|
||||||
|
self.slot_start = slotid_to_utc(slotid)
|
||||||
|
self.guildid = guildid
|
||||||
|
self.locale = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def starting_soon(self):
|
||||||
|
return (self.slot_start - utc_now()).total_seconds() < 60
|
||||||
|
|
||||||
|
async def init_components(self):
|
||||||
|
"""
|
||||||
|
Localise components.
|
||||||
|
"""
|
||||||
|
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
|
||||||
|
locale = self.locale = lguild.locale
|
||||||
|
t = self.bot.translator.t
|
||||||
|
|
||||||
|
self.book_button.label = t(_p(
|
||||||
|
'ui:sessionui|button:book|label',
|
||||||
|
"Book"
|
||||||
|
), locale)
|
||||||
|
self.cancel_button.label = t(_p(
|
||||||
|
'ui:sessionui|button:cancel|label',
|
||||||
|
"Cancel"
|
||||||
|
), locale)
|
||||||
|
self.schedule_button.label = t(_p(
|
||||||
|
'ui:sessionui|button:schedule|label',
|
||||||
|
'Open Schedule'
|
||||||
|
), locale)
|
||||||
|
|
||||||
|
# ----- API -----
|
||||||
|
async def reload(self):
|
||||||
|
await self.init_components()
|
||||||
|
if self.starting_soon:
|
||||||
|
# Slot is about to start or slot has already started
|
||||||
|
self.set_layout((self.schedule_button,))
|
||||||
|
else:
|
||||||
|
self.set_layout(
|
||||||
|
(self.book_button, self.cancel_button, self.schedule_button),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----- UI Components -----
|
||||||
|
@button(label='BOOK_PLACEHOLDER', style=ButtonStyle.blurple)
|
||||||
|
async def book_button(self, press: discord.Interaction, pressed: Button):
|
||||||
|
await press.response.defer(thinking=True, ephemeral=True)
|
||||||
|
t = self.bot.translator.t
|
||||||
|
babel = self.bot.get_cog('BabelCog')
|
||||||
|
locale = await babel.get_user_locale(press.user.id)
|
||||||
|
ctx_locale.set(locale)
|
||||||
|
|
||||||
|
error = None
|
||||||
|
if self.starting_soon:
|
||||||
|
error = t(_p(
|
||||||
|
'ui:session|button:book|error:starting_soon',
|
||||||
|
"Too late! This session has started or is starting shortly."
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
schedule = await self.cog._fetch_schedule(press.user.id)
|
||||||
|
if self.slotid in schedule:
|
||||||
|
error = t(_p(
|
||||||
|
'ui:session|button:book|error:already_booked',
|
||||||
|
"You are already a member of this session!"
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await self.cog.create_booking(self.guildid, press.user.id, self.slotid)
|
||||||
|
ack = t(_p(
|
||||||
|
'ui:session|button:book|success',
|
||||||
|
"Successfully booked this session."
|
||||||
|
))
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.brand_green(),
|
||||||
|
description=ack
|
||||||
|
)
|
||||||
|
except UserInputError as e:
|
||||||
|
error = e.msg
|
||||||
|
if error is not None:
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.brand_red(),
|
||||||
|
description=error,
|
||||||
|
title=t(_p(
|
||||||
|
'ui:session|button:book|error|title',
|
||||||
|
"Could not book session"
|
||||||
|
))
|
||||||
|
)
|
||||||
|
|
||||||
|
await press.edit_original_response(embed=embed)
|
||||||
|
|
||||||
|
@button(label='CANCEL_PLACHEHOLDER', style=ButtonStyle.blurple)
|
||||||
|
async def cancel_button(self, press: discord.Interaction, pressed: Button):
|
||||||
|
await press.response.defer(thinking=True, ephemeral=True)
|
||||||
|
t = self.bot.translator.t
|
||||||
|
babel = self.bot.get_cog('BabelCog')
|
||||||
|
locale = await babel.get_user_locale(press.user.id)
|
||||||
|
ctx_locale.set(locale)
|
||||||
|
|
||||||
|
error = None
|
||||||
|
if self.starting_soon:
|
||||||
|
error = t(_p(
|
||||||
|
'ui:session|button:cancel|error:starting_soon',
|
||||||
|
"Too late! This session has started or is starting shortly."
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
schedule = await self.cog._fetch_schedule(press.user.id)
|
||||||
|
if self.slotid not in schedule:
|
||||||
|
error = t(_p(
|
||||||
|
'ui:session|button:cancel|error:not_booked',
|
||||||
|
"You are not a member of this session!"
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await self.cog.cancel_bookings(
|
||||||
|
(self.slotid, self.guildid, press.user.id),
|
||||||
|
refund=True
|
||||||
|
)
|
||||||
|
ack = t(_p(
|
||||||
|
'ui:session|button:cancel|success',
|
||||||
|
"Successfully cancelled this session."
|
||||||
|
))
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.brand_green(),
|
||||||
|
description=ack
|
||||||
|
)
|
||||||
|
except UserInputError as e:
|
||||||
|
error = e.msg
|
||||||
|
if error is not None:
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.brand_red(),
|
||||||
|
description=error,
|
||||||
|
title=t(_p(
|
||||||
|
'ui:session|button:cancel|error|title',
|
||||||
|
"Could not cancel session"
|
||||||
|
))
|
||||||
|
)
|
||||||
|
|
||||||
|
await press.edit_original_response(embed=embed)
|
||||||
|
|
||||||
|
@button(label='SCHEDULE_PLACEHOLDER', style=ButtonStyle.blurple)
|
||||||
|
async def schedule_button(self, press: discord.Interaction, pressed: Button):
|
||||||
|
await press.response.defer(thinking=True, ephemeral=True)
|
||||||
|
|
||||||
|
babel = self.bot.get_cog('BabelCog')
|
||||||
|
locale = await babel.get_user_locale(press.user.id)
|
||||||
|
ctx_locale.set(locale)
|
||||||
|
|
||||||
|
ui = ScheduleUI(self.bot, press.guild, press.user.id)
|
||||||
|
await ui.run(press)
|
||||||
|
await ui.wait()
|
||||||
233
src/modules/schedule/ui/settingui.py
Normal file
233
src/modules/schedule/ui/settingui.py
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import itertools
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord.ui.button import button, Button, ButtonStyle
|
||||||
|
from discord.ui.select import select, ChannelSelect, RoleSelect
|
||||||
|
|
||||||
|
from meta import LionBot
|
||||||
|
|
||||||
|
from utils.ui import ConfigUI, DashboardSection
|
||||||
|
from utils.lib import MessageArgs
|
||||||
|
|
||||||
|
from ..settings import ScheduleSettings
|
||||||
|
from .. import babel
|
||||||
|
|
||||||
|
_p = babel._p
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleSettingUI(ConfigUI):
|
||||||
|
pages = [
|
||||||
|
(
|
||||||
|
ScheduleSettings.SessionLobby,
|
||||||
|
ScheduleSettings.SessionRoom,
|
||||||
|
ScheduleSettings.SessionChannels,
|
||||||
|
ScheduleSettings.ScheduleCost,
|
||||||
|
), (
|
||||||
|
ScheduleSettings.AttendanceReward,
|
||||||
|
ScheduleSettings.AttendanceBonus,
|
||||||
|
ScheduleSettings.MinAttendance,
|
||||||
|
), (
|
||||||
|
ScheduleSettings.BlacklistRole,
|
||||||
|
ScheduleSettings.BlacklistAfter,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
setting_classes = list(itertools.chain(*pages))
|
||||||
|
|
||||||
|
def _init_children(self):
|
||||||
|
# HACK to stop ViewWeights complaining that this UI has too many children
|
||||||
|
# Children will be correctly initialised after parent init.
|
||||||
|
return []
|
||||||
|
|
||||||
|
def __init__(self, bot: LionBot, guildid: int, channelid: int, **kwargs):
|
||||||
|
self.settings = bot.get_cog('ScheduleCog').settings
|
||||||
|
super().__init__(bot, guildid, channelid, **kwargs)
|
||||||
|
self._children = super()._init_children()
|
||||||
|
self.page_num = 0
|
||||||
|
|
||||||
|
def get_instance(self, setting):
|
||||||
|
return next(instance for instance in self.instances if instance.setting_id == setting.setting_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def page_instances(self):
|
||||||
|
start = sum(len(page) for page in self.pages[:self.page_num])
|
||||||
|
end = start + len(self.pages[self.page_num])
|
||||||
|
return self.instances[start:end]
|
||||||
|
|
||||||
|
# ----- UI Components -----
|
||||||
|
# Page 0 button
|
||||||
|
@button(label="PAGE0_BUTTON_PLACEHOLDER", style=ButtonStyle.grey)
|
||||||
|
async def page0_button(self, press: discord.Interaction, pressed: Button):
|
||||||
|
await press.response.defer(thinking=True, ephemeral=True)
|
||||||
|
self.page_num = 0
|
||||||
|
await self.refresh(thinking=press)
|
||||||
|
|
||||||
|
async def page0_button_refresh(self):
|
||||||
|
t = self.bot.translator.t
|
||||||
|
self.page0_button.label = t(_p(
|
||||||
|
'ui:schedule_config|button:page0|label',
|
||||||
|
"Page 1"
|
||||||
|
))
|
||||||
|
self.page0_button.disabled = (self.page_num == 0)
|
||||||
|
|
||||||
|
# Lobby channel selector
|
||||||
|
@select(cls=ChannelSelect, channel_types=[discord.ChannelType.text, discord.ChannelType.voice],
|
||||||
|
min_values=0, max_values=1,
|
||||||
|
placeholder='LOBBY_PLACEHOLDER')
|
||||||
|
async def lobby_menu(self, selection: discord.Interaction, selected: ChannelSelect):
|
||||||
|
# TODO: Setting value checks
|
||||||
|
await selection.response.defer()
|
||||||
|
setting = self.get_instance(ScheduleSettings.SessionLobby)
|
||||||
|
setting.value = selected.values[0] if selected.values else None
|
||||||
|
await setting.write()
|
||||||
|
|
||||||
|
async def lobby_menu_refresh(self):
|
||||||
|
t = self.bot.translator.t
|
||||||
|
self.lobby_menu.placeholder = t(_p(
|
||||||
|
'ui:schedule_config|menu:lobby|placeholder',
|
||||||
|
"Select Lobby Channel"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Room channel selector
|
||||||
|
@select(cls=ChannelSelect, channel_types=[discord.ChannelType.voice],
|
||||||
|
min_values=0, max_values=1,
|
||||||
|
placeholder='ROOM_PLACEHOLDER')
|
||||||
|
async def room_menu(self, selection: discord.Interaction, selected: ChannelSelect):
|
||||||
|
await selection.response.defer()
|
||||||
|
setting = self.get_instance(ScheduleSettings.SessionRoom)
|
||||||
|
setting.value = selected.values[0] if selected.values else None
|
||||||
|
await setting.write()
|
||||||
|
|
||||||
|
async def room_menu_refresh(self):
|
||||||
|
t = self.bot.translator.t
|
||||||
|
self.room_menu.placeholder = t(_p(
|
||||||
|
'ui:schedule_config|menu:room|placeholder',
|
||||||
|
"Select Session Room"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Session channels selector
|
||||||
|
@select(cls=ChannelSelect, channel_types=[discord.ChannelType.category, discord.ChannelType.voice],
|
||||||
|
min_values=0, max_values=25,
|
||||||
|
placeholder='CHANNELS_PLACEHOLDER')
|
||||||
|
async def channels_menu(self, selection: discord.Interaction, selected: ChannelSelect):
|
||||||
|
# TODO: Consider XORing input
|
||||||
|
await selection.response.defer()
|
||||||
|
setting = self.get_instance(ScheduleSettings.SessionChannels)
|
||||||
|
setting.value = selected.values
|
||||||
|
await setting.write()
|
||||||
|
|
||||||
|
async def channels_menu_refresh(self):
|
||||||
|
t = self.bot.translator.t
|
||||||
|
self.channels_menu.placeholder = t(_p(
|
||||||
|
'ui:schedule_config|menu:channels|placeholder',
|
||||||
|
"Select Session Channels"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Page 1 button
|
||||||
|
@button(label="PAGE1_BUTTON_PLACEHOLDER", style=ButtonStyle.grey)
|
||||||
|
async def page1_button(self, press: discord.Interaction, pressed: Button):
|
||||||
|
await press.response.defer(thinking=True, ephemeral=True)
|
||||||
|
self.page_num = 1
|
||||||
|
await self.refresh(thinking=press)
|
||||||
|
|
||||||
|
async def page1_button_refresh(self):
|
||||||
|
t = self.bot.translator.t
|
||||||
|
self.page1_button.label = t(_p(
|
||||||
|
'ui:schedule_config|button:page1|label',
|
||||||
|
"Page 2"
|
||||||
|
))
|
||||||
|
self.page1_button.disabled = (self.page_num == 1)
|
||||||
|
|
||||||
|
# Page 3 button
|
||||||
|
@button(label="PAGE2_BUTTON_PLACEHOLDER", style=ButtonStyle.grey)
|
||||||
|
async def page2_button(self, press: discord.Interaction, pressed: Button):
|
||||||
|
await press.response.defer(thinking=True, ephemeral=True)
|
||||||
|
self.page_num = 2
|
||||||
|
await self.refresh(thinking=press)
|
||||||
|
|
||||||
|
async def page2_button_refresh(self):
|
||||||
|
t = self.bot.translator.t
|
||||||
|
self.page2_button.label = t(_p(
|
||||||
|
'ui:schedule_config|button:page2|label',
|
||||||
|
"Page 3"
|
||||||
|
))
|
||||||
|
self.page2_button.disabled = (self.page_num == 3)
|
||||||
|
|
||||||
|
# Blacklist role selector
|
||||||
|
@select(cls=RoleSelect, min_values=0, max_values=1, placeholder="BLACKLIST_ROLE_PLACEHOLDER")
|
||||||
|
async def blacklist_role_menu(self, selection: discord.Interaction, selected: RoleSelect):
|
||||||
|
await selection.response.defer()
|
||||||
|
setting = self.get_instance(ScheduleSettings.BlacklistRole)
|
||||||
|
setting.value = selected.values[0] if selected.values else None
|
||||||
|
# TODO: Warning for insufficient permissions?
|
||||||
|
await setting.write()
|
||||||
|
|
||||||
|
async def blacklist_role_menu_refresh(self):
|
||||||
|
t = self.bot.translator.t
|
||||||
|
self.blacklist_role_menu.placeholder = t(_p(
|
||||||
|
'ui:schedule_config|menu:blacklist_role|placeholder',
|
||||||
|
"Select Blacklist Role"
|
||||||
|
))
|
||||||
|
|
||||||
|
# ----- UI Flow -----
|
||||||
|
async def make_message(self) -> MessageArgs:
|
||||||
|
t = self.bot.translator.t
|
||||||
|
title = t(_p(
|
||||||
|
'ui:schedule_config|embed|title',
|
||||||
|
"Scheduled Session Configuration Panel"
|
||||||
|
))
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.orange(),
|
||||||
|
title=title
|
||||||
|
)
|
||||||
|
for setting in self.page_instances:
|
||||||
|
embed.add_field(**setting.embed_field, inline=False)
|
||||||
|
|
||||||
|
args = MessageArgs(embed=embed)
|
||||||
|
return args
|
||||||
|
|
||||||
|
async def refresh_components(self):
|
||||||
|
await asyncio.gather(
|
||||||
|
self.page0_button_refresh(),
|
||||||
|
self.page1_button_refresh(),
|
||||||
|
self.page2_button_refresh(),
|
||||||
|
self.edit_button_refresh(),
|
||||||
|
self.reset_button_refresh(),
|
||||||
|
self.close_button_refresh(),
|
||||||
|
)
|
||||||
|
if self.page_num == 0:
|
||||||
|
await asyncio.gather(
|
||||||
|
self.lobby_menu_refresh(),
|
||||||
|
self.room_menu_refresh(),
|
||||||
|
self.channels_menu_refresh(),
|
||||||
|
)
|
||||||
|
self.set_layout(
|
||||||
|
(self.page0_button, self.page1_button, self.page2_button),
|
||||||
|
(self.lobby_menu,),
|
||||||
|
(self.room_menu,),
|
||||||
|
(self.channels_menu,),
|
||||||
|
(self.edit_button, self.reset_button, self.close_button),
|
||||||
|
)
|
||||||
|
elif self.page_num == 1:
|
||||||
|
self.set_layout(
|
||||||
|
(self.page0_button, self.page1_button, self.page2_button),
|
||||||
|
(self.edit_button, self.reset_button, self.close_button),
|
||||||
|
)
|
||||||
|
elif self.page_num == 2:
|
||||||
|
await asyncio.gather(
|
||||||
|
self.blacklist_role_menu_refresh()
|
||||||
|
)
|
||||||
|
self.set_layout(
|
||||||
|
(self.page0_button, self.page1_button, self.page2_button),
|
||||||
|
(self.blacklist_role_menu,),
|
||||||
|
(self.edit_button, self.reset_button, self.close_button),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleDashboard(DashboardSection):
|
||||||
|
section_name = _p(
|
||||||
|
'dash:schedule|title',
|
||||||
|
"Scheduled Session Configuration ({commands[configure schedule]})"
|
||||||
|
)
|
||||||
|
configui = ScheduleSettingUI
|
||||||
|
setting_classes = ScheduleSettingUI.setting_classes
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import Optional, Iterable
|
from typing import Optional, Iterable
|
||||||
|
import datetime as dt
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from psycopg import sql
|
from psycopg import sql
|
||||||
@@ -78,6 +79,38 @@ class StatsData(Registry):
|
|||||||
duration = Integer()
|
duration = Integer()
|
||||||
end_time = Timestamp()
|
end_time = Timestamp()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def tracked_time_between(cls, *points: tuple[int, int, dt.datetime, dt.datetime]):
|
||||||
|
query = sql.SQL(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
t._guildid AS guildid,
|
||||||
|
t._userid AS userid,
|
||||||
|
t._start AS start_time,
|
||||||
|
t._end AS end_time,
|
||||||
|
study_time_between(t._guildid, t._userid, t._start, t._end) AS stime
|
||||||
|
FROM
|
||||||
|
(VALUES {})
|
||||||
|
AS
|
||||||
|
t (_guildid, _userid, _start, _end)
|
||||||
|
"""
|
||||||
|
).format(
|
||||||
|
sql.SQL(', ').join(
|
||||||
|
sql.SQL("({}, {}, {}, {})").format(
|
||||||
|
sql.Placeholder(), sql.Placeholder(),
|
||||||
|
sql.Placeholder(), sql.Placeholder()
|
||||||
|
)
|
||||||
|
for _ in points
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn = await cls._connector.get_connection()
|
||||||
|
async with conn.cursor() as cursor:
|
||||||
|
await cursor.execute(
|
||||||
|
query,
|
||||||
|
chain(*points)
|
||||||
|
)
|
||||||
|
return cursor.fetchall()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def study_time_between(cls, guildid: int, userid: int, _start, _end) -> int:
|
async def study_time_between(cls, guildid: int, userid: int, _start, _end) -> int:
|
||||||
conn = await cls._connector.get_connection()
|
conn = await cls._connector.get_connection()
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ class VoiceTrackerCog(LionCog):
|
|||||||
self.babel = babel
|
self.babel = babel
|
||||||
|
|
||||||
# State
|
# State
|
||||||
|
# Flag indicating whether local voice sessions have been initialised
|
||||||
|
self.initialised = asyncio.Event()
|
||||||
self.handle_events = False
|
self.handle_events = False
|
||||||
self.tracking_lock = asyncio.Lock()
|
self.tracking_lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -92,6 +94,7 @@ class VoiceTrackerCog(LionCog):
|
|||||||
|
|
||||||
logger.debug("Disabling voice state event handling.")
|
logger.debug("Disabling voice state event handling.")
|
||||||
self.handle_events = False
|
self.handle_events = False
|
||||||
|
self.initialised.clear()
|
||||||
# Read and save the tracked voice states of all visible voice channels
|
# Read and save the tracked voice states of all visible voice channels
|
||||||
voice_members = {} # (guildid, userid) -> TrackedVoiceState
|
voice_members = {} # (guildid, userid) -> TrackedVoiceState
|
||||||
voice_guilds = set()
|
voice_guilds = set()
|
||||||
@@ -252,6 +255,7 @@ class VoiceTrackerCog(LionCog):
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
VoiceSession.from_ongoing(self.bot, row, expiries[(row.guildid, row.userid)])
|
VoiceSession.from_ongoing(self.bot, row, expiries[(row.guildid, row.userid)])
|
||||||
logger.info(f"Started {len(rows)} new voice sessions from voice channels!")
|
logger.info(f"Started {len(rows)} new voice sessions from voice channels!")
|
||||||
|
self.initialised.set()
|
||||||
|
|
||||||
@LionCog.listener("on_voice_state_update")
|
@LionCog.listener("on_voice_state_update")
|
||||||
@log_wrap(action='Voice Track')
|
@log_wrap(action='Voice Track')
|
||||||
@@ -259,7 +263,6 @@ class VoiceTrackerCog(LionCog):
|
|||||||
"""
|
"""
|
||||||
Spawns the correct tasks from members joining, leaving, and changing live state.
|
Spawns the correct tasks from members joining, leaving, and changing live state.
|
||||||
"""
|
"""
|
||||||
# TODO: Logging context
|
|
||||||
if not self.handle_events:
|
if not self.handle_events:
|
||||||
# Rely on initialisation to handle current state
|
# Rely on initialisation to handle current state
|
||||||
return
|
return
|
||||||
@@ -505,7 +508,7 @@ class VoiceTrackerCog(LionCog):
|
|||||||
delay = (tomorrow - now).total_seconds()
|
delay = (tomorrow - now).total_seconds()
|
||||||
else:
|
else:
|
||||||
start_time = now
|
start_time = now
|
||||||
delay = 60
|
delay = 20
|
||||||
|
|
||||||
expiry = start_time + dt.timedelta(seconds=cap)
|
expiry = start_time + dt.timedelta(seconds=cap)
|
||||||
if expiry >= tomorrow:
|
if expiry >= tomorrow:
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ class VoiceSession:
|
|||||||
live_video=state.video,
|
live_video=state.video,
|
||||||
hourly_coins=self.hourly_rate
|
hourly_coins=self.hourly_rate
|
||||||
)
|
)
|
||||||
|
self.bot.dispatch('voice_session_start', self.data)
|
||||||
self.start_task = None
|
self.start_task = None
|
||||||
|
|
||||||
def schedule_expiry(self, expire_time):
|
def schedule_expiry(self, expire_time):
|
||||||
@@ -230,7 +231,11 @@ class VoiceSession:
|
|||||||
"""
|
"""
|
||||||
if self.activity is SessionState.ONGOING:
|
if self.activity is SessionState.ONGOING:
|
||||||
# End the ongoing session
|
# End the ongoing session
|
||||||
await self.data.close_study_session_at(self.guildid, self.userid, utc_now())
|
now = utc_now()
|
||||||
|
await self.data.close_study_session_at(self.guildid, self.userid, now)
|
||||||
|
|
||||||
|
# TODO: Something a bit saner/safer.. dispatch the finished session instead?
|
||||||
|
self.bot.dispatch('voice_session_end', self.data, now)
|
||||||
|
|
||||||
# Rank update
|
# Rank update
|
||||||
# TODO: Change to broadcasted event?
|
# TODO: Change to broadcasted event?
|
||||||
|
|||||||
162
src/utils/data.py
Normal file
162
src/utils/data.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""
|
||||||
|
Some useful pre-built Conditions for data queries.
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from itertools import chain
|
||||||
|
|
||||||
|
from psycopg import sql
|
||||||
|
from data.conditions import Condition, Joiner
|
||||||
|
from data.columns import ColumnExpr
|
||||||
|
from data.base import Expression
|
||||||
|
from constants import MAX_COINS
|
||||||
|
|
||||||
|
|
||||||
|
def MULTIVALUE_IN(columns: tuple[str, ...], *data: tuple[...]) -> Condition:
|
||||||
|
"""
|
||||||
|
Condition constructor for filtering by multiple column equalities.
|
||||||
|
|
||||||
|
Example Usage
|
||||||
|
-------------
|
||||||
|
Query.where(MULTIVALUE_IN(('guildid', 'userid'), (1, 2), (3, 4)))
|
||||||
|
"""
|
||||||
|
if not data:
|
||||||
|
raise ValueError("Cannot create empty multivalue condition.")
|
||||||
|
left = sql.SQL("({})").format(
|
||||||
|
sql.SQL(', ').join(
|
||||||
|
sql.Identifier(key)
|
||||||
|
for key in columns
|
||||||
|
)
|
||||||
|
)
|
||||||
|
right_item = sql.SQL('({})').format(
|
||||||
|
sql.SQL(', ').join(
|
||||||
|
sql.Placeholder()
|
||||||
|
for _ in columns
|
||||||
|
)
|
||||||
|
)
|
||||||
|
right = sql.SQL("({})").format(
|
||||||
|
sql.SQL(', ').join(
|
||||||
|
right_item
|
||||||
|
for _ in data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return Condition(
|
||||||
|
left,
|
||||||
|
Joiner.IN,
|
||||||
|
right,
|
||||||
|
chain(*data)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def MEMBERS(*memberids: tuple[int, int], guild_column='guildid', user_column='userid') -> Condition:
|
||||||
|
"""
|
||||||
|
Condition constructor for filtering member tables by guild and user id simultaneously.
|
||||||
|
|
||||||
|
Example Usage
|
||||||
|
-------------
|
||||||
|
Query.where(MEMBERS((1234,12), (5678,34)))
|
||||||
|
"""
|
||||||
|
if not memberids:
|
||||||
|
raise ValueError("Cannot create a condition with no members")
|
||||||
|
return Condition(
|
||||||
|
sql.SQL("({guildid}, {userid})").format(
|
||||||
|
guildid=sql.Identifier(guild_column),
|
||||||
|
userid=sql.Identifier(user_column)
|
||||||
|
),
|
||||||
|
Joiner.IN,
|
||||||
|
sql.SQL("({})").format(
|
||||||
|
sql.SQL(', ').join(
|
||||||
|
sql.SQL("({}, {})").format(
|
||||||
|
sql.Placeholder(),
|
||||||
|
sql.Placeholder()
|
||||||
|
) for _ in memberids
|
||||||
|
)
|
||||||
|
),
|
||||||
|
chain(*memberids)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def as_duration(expr: Expression) -> ColumnExpr:
|
||||||
|
"""
|
||||||
|
Convert an integer expression into a duration expression.
|
||||||
|
"""
|
||||||
|
expr_expr, expr_values = expr.as_tuple()
|
||||||
|
return ColumnExpr(
|
||||||
|
sql.SQL("({} * interval '1 second')").format(expr_expr),
|
||||||
|
expr_values
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TemporaryTable(Expression):
|
||||||
|
"""
|
||||||
|
Create a temporary table expression to be used in From or With clauses.
|
||||||
|
|
||||||
|
Example
|
||||||
|
-------
|
||||||
|
```
|
||||||
|
tmp_table = TemporaryTable('_col1', '_col2', name='data')
|
||||||
|
tmp_table.values((1, 2), (3, 4))
|
||||||
|
|
||||||
|
real_table.update_where(col1=tmp_table['_col1']).set(col2=tmp_table['_col2']).from_(tmp_table)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *columns: str, name: str = '_t', types: Optional[tuple[str]] = None):
|
||||||
|
self.name = name
|
||||||
|
self.columns = columns
|
||||||
|
self.types = types
|
||||||
|
if types and len(types) != len(columns):
|
||||||
|
raise ValueError("Number of types does not much number of columns!")
|
||||||
|
|
||||||
|
self._table_columns = {
|
||||||
|
col: ColumnExpr(sql.Identifier(name, col))
|
||||||
|
for col in columns
|
||||||
|
}
|
||||||
|
|
||||||
|
self.values = []
|
||||||
|
|
||||||
|
def __getitem__(self, key) -> sql.Identifier:
|
||||||
|
return self._table_columns[key]
|
||||||
|
|
||||||
|
def as_tuple(self):
|
||||||
|
"""
|
||||||
|
(VALUES {})
|
||||||
|
AS
|
||||||
|
name (col1, col2)
|
||||||
|
"""
|
||||||
|
single_value = sql.SQL("({})").format(sql.SQL(", ").join(sql.Placeholder() for _ in self.columns))
|
||||||
|
if self.types:
|
||||||
|
first_value = sql.SQL("({})").format(
|
||||||
|
sql.SQL(", ").join(
|
||||||
|
sql.SQL("{}::{}").format(sql.Placeholder(), sql.SQL(cast))
|
||||||
|
for cast in self.types
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
first_value = single_value
|
||||||
|
|
||||||
|
value_placeholder = sql.SQL("(VALUES {})").format(
|
||||||
|
sql.SQL(", ").join(
|
||||||
|
(first_value, *(single_value for _ in self.values[1:]))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expr = sql.SQL("{values} AS {name} ({columns})").format(
|
||||||
|
values=value_placeholder,
|
||||||
|
name=sql.Identifier(self.name),
|
||||||
|
columns=sql.SQL(", ").join(sql.Identifier(col) for col in self.columns)
|
||||||
|
)
|
||||||
|
values = chain(*self.values)
|
||||||
|
return (expr, values)
|
||||||
|
|
||||||
|
def set_values(self, *data):
|
||||||
|
self.values = data
|
||||||
|
|
||||||
|
|
||||||
|
def SAFECOINS(expr: Expression) -> Expression:
|
||||||
|
expr_expr, expr_values = expr.as_tuple()
|
||||||
|
return ColumnExpr(
|
||||||
|
sql.SQL("LEAST({}, {})").format(
|
||||||
|
expr_expr,
|
||||||
|
sql.Literal(MAX_COINS)
|
||||||
|
),
|
||||||
|
expr_values
|
||||||
|
)
|
||||||
@@ -85,6 +85,7 @@ class Bucket:
|
|||||||
# Wrapped in a lock so that waiters are correctly handled in wait-order
|
# Wrapped in a lock so that waiters are correctly handled in wait-order
|
||||||
# Otherwise multiple waiters will have the same delay,
|
# Otherwise multiple waiters will have the same delay,
|
||||||
# and race for the wakeup after sleep.
|
# and race for the wakeup after sleep.
|
||||||
|
# Also avoids short-circuiting in the 0 delay case, which would not correctly handle wait-order
|
||||||
async with self._wait_lock:
|
async with self._wait_lock:
|
||||||
# We do this in a loop in case asyncio.sleep throws us out early,
|
# We do this in a loop in case asyncio.sleep throws us out early,
|
||||||
# or a synchronous request overflows the bucket while we are waiting.
|
# or a synchronous request overflows the bucket while we are waiting.
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class ConfigUI(LeoUI):
|
|||||||
# Instances of the settings this UI is managing
|
# Instances of the settings this UI is managing
|
||||||
self.instances = ()
|
self.instances = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def page_instances(self):
|
||||||
|
return self.instances
|
||||||
|
|
||||||
async def interaction_check(self, interaction: discord.Interaction):
|
async def interaction_check(self, interaction: discord.Interaction):
|
||||||
"""
|
"""
|
||||||
Default requirement for a Config UI is low management (i.e. manage_guild permissions).
|
Default requirement for a Config UI is low management (i.e. manage_guild permissions).
|
||||||
@@ -95,7 +99,7 @@ class ConfigUI(LeoUI):
|
|||||||
Errors should raise instances of `UserInputError`, and will be caught for retry.
|
Errors should raise instances of `UserInputError`, and will be caught for retry.
|
||||||
"""
|
"""
|
||||||
t = ctx_translator.get().t
|
t = ctx_translator.get().t
|
||||||
instances = self.instances
|
instances = self.page_instances
|
||||||
items = [setting.input_field for setting in instances]
|
items = [setting.input_field for setting in instances]
|
||||||
# Filter out settings which don't have input fields
|
# Filter out settings which don't have input fields
|
||||||
items = [item for item in items if item]
|
items = [item for item in items if item]
|
||||||
@@ -174,7 +178,7 @@ class ConfigUI(LeoUI):
|
|||||||
"""
|
"""
|
||||||
await press.response.defer()
|
await press.response.defer()
|
||||||
|
|
||||||
for instance in self.instances:
|
for instance in self.page_instances:
|
||||||
instance.data = None
|
instance.data = None
|
||||||
await instance.write()
|
await instance.write()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user