rewrite: Initial rewrite skeleton.

Remove modules that will no longer be required.
Move pending modules to pending-rewrite folders.
This commit is contained in:
2022-09-17 17:06:13 +10:00
parent a7f7dd6e7b
commit a5147323b5
162 changed files with 1 additions and 866 deletions

View File

@@ -0,0 +1,16 @@
from .sysadmin import *
from .guild_admin import *
from .meta import *
from .economy import *
from .study import *
from .stats import *
from .user_config import *
from .workout import *
from .todo import *
from .topgg import *
from .reminders import *
from .renting import *
from .moderation import *
from .accountability import *
from .plugins import *
from .sponsors import *

View File

@@ -0,0 +1,477 @@
from typing import List, Dict
import datetime
import discord
import asyncio
import random
from settings import GuildSettings
from utils.lib import tick, cross
from core import Lion
from meta import client
from .lib import utc_now
from .data import accountability_members, accountability_rooms
class SlotMember:
"""
Class representing a member booked into an accountability room.
Mostly acts as an interface to the corresponding TableRow.
But also stores the discord.Member associated, and has several computed properties.
The member may be None.
"""
___slots__ = ('slotid', 'userid', 'guild')
def __init__(self, slotid, userid, guild):
self.slotid = slotid
self.userid = userid
self.guild = guild
self._member = None
@property
def key(self):
return (self.slotid, self.userid)
@property
def data(self):
return accountability_members.fetch(self.key)
@property
def member(self):
return self.guild.get_member(self.userid)
@property
def has_attended(self):
return self.data.duration > 0 or self.data.last_joined_at
class TimeSlot:
"""
Class representing an accountability slot.
"""
__slots__ = (
'guild',
'start_time',
'data',
'lobby',
'category',
'channel',
'message',
'members'
)
slots = {}
_member_overwrite = discord.PermissionOverwrite(
view_channel=True,
connect=True
)
_everyone_overwrite = discord.PermissionOverwrite(
view_channel=False,
connect=False,
speak=False
)
happy_lion = "https://media.discordapp.net/stickers/898266283559227422.png"
sad_lion = "https://media.discordapp.net/stickers/898266548421148723.png"
def __init__(self, guild, start_time, data=None):
self.guild: discord.Guild = guild
self.start_time: datetime.datetime = start_time
self.data = data
self.lobby: discord.TextChannel = None # Text channel to post the slot status
self.category: discord.CategoryChannel = None # Category to create the voice rooms in
self.channel: discord.VoiceChannel = None # Text channel associated with this time slot
self.message: discord.Message = None # Status message in lobby channel
self.members: Dict[int, SlotMember] = {} # memberid -> SlotMember
@property
def open_embed(self):
timestamp = int(self.start_time.timestamp())
embed = discord.Embed(
title="Session <t:{}:t> - <t:{}:t>".format(
timestamp, timestamp + 3600
),
colour=discord.Colour.orange(),
timestamp=self.start_time
).set_footer(
text="About to start!\nJoin the session with {}schedule book".format(client.prefix)
)
if self.members:
embed.description = "Starting <t:{}:R>.".format(timestamp)
embed.add_field(
name="Members",
value=(
', '.join('<@{}>'.format(key) for key in self.members.keys())
)
)
else:
embed.description = "No members scheduled this session!"
return embed
@property
def status_embed(self):
timestamp = int(self.start_time.timestamp())
embed = discord.Embed(
title="Session <t:{}:t> - <t:{}:t>".format(
timestamp, timestamp + 3600
),
description="Finishing <t:{}:R>.".format(timestamp + 3600),
colour=discord.Colour.orange(),
timestamp=self.start_time
).set_footer(text="Join the next session using {}schedule book".format(client.prefix))
if self.members:
classifications = {
"Attended": [],
"Studying Now": [],
"Waiting for": []
}
for memid, mem in self.members.items():
mention = '<@{}>'.format(memid)
if not mem.has_attended:
classifications["Waiting for"].append(mention)
elif mem.member in self.channel.members:
classifications["Studying Now"].append(mention)
else:
classifications["Attended"].append(mention)
all_attended = all(mem.has_attended for mem in self.members.values())
bonus_line = (
"{tick} Everyone attended, and will get a `{bonus} LC` bonus!".format(
tick=tick,
bonus=GuildSettings(self.guild.id).accountability_bonus.value
)
if all_attended else ""
)
if all_attended:
embed.set_thumbnail(url=self.happy_lion)
embed.description += "\n" + bonus_line
for field, value in classifications.items():
if value:
embed.add_field(name=field, value='\n'.join(value))
else:
embed.description = "No members scheduled this session!"
return embed
@property
def summary_embed(self):
timestamp = int(self.start_time.timestamp())
embed = discord.Embed(
title="Session <t:{}:t> - <t:{}:t>".format(
timestamp, timestamp + 3600
),
description="Finished <t:{}:R>.".format(timestamp + 3600),
colour=discord.Colour.orange(),
timestamp=self.start_time
).set_footer(text="Completed!")
if self.members:
classifications = {
"Attended": [],
"Missing": []
}
for memid, mem in sorted(self.members.items(), key=lambda mem: mem[1].data.duration, reverse=True):
mention = '<@{}>'.format(memid)
if mem.has_attended:
classifications["Attended"].append(
"{} ({}%)".format(mention, (mem.data.duration * 100) // 3600)
)
else:
classifications["Missing"].append(mention)
all_attended = all(mem.has_attended for mem in self.members.values())
bonus_line = (
"{tick} Everyone attended, and received a `{bonus} LC` bonus!".format(
tick=tick,
bonus=GuildSettings(self.guild.id).accountability_bonus.value
)
if all_attended else
"{cross} Some members missed the session, so everyone missed out on the bonus!".format(
cross=cross
)
)
if all_attended:
embed.set_thumbnail(url=self.happy_lion)
else:
embed.set_thumbnail(url=self.sad_lion)
embed.description += "\n" + bonus_line
for field, value in classifications.items():
if value:
embed.add_field(name=field, value='\n'.join(value))
else:
embed.description = "No members scheduled this session!"
return embed
def load(self, memberids: List[int] = None):
"""
Load data and update applicable caches.
"""
if not self.guild:
return self
# Load setting data
self.category = GuildSettings(self.guild.id).accountability_category.value
self.lobby = GuildSettings(self.guild.id).accountability_lobby.value
if self.data:
# Load channel
if self.data.channelid:
self.channel = self.guild.get_channel(self.data.channelid)
# Load message
if self.data.messageid and self.lobby:
self.message = discord.PartialMessage(
channel=self.lobby,
id=self.data.messageid
)
# Load members
if memberids:
self.members = {
memberid: SlotMember(self.data.slotid, memberid, self.guild)
for memberid in memberids
}
return self
async def _reload_members(self, memberids=None):
"""
Reload the timeslot members from the provided list, or data.
Also updates the channel overwrites if required.
To be used before the session has started.
"""
if self.data:
if memberids is None:
member_rows = accountability_members.fetch_rows_where(slotid=self.data.slotid)
memberids = [row.userid for row in member_rows]
self.members = members = {
memberid: SlotMember(self.data.slotid, memberid, self.guild)
for memberid in memberids
}
if self.channel:
# Check and potentially update overwrites
current_overwrites = self.channel.overwrites
overwrites = {
mem.member: self._member_overwrite
for mem in members.values()
if mem.member
}
overwrites[self.guild.default_role] = self._everyone_overwrite
if current_overwrites != overwrites:
await self.channel.edit(overwrites=overwrites)
def _refresh(self):
"""
Refresh the stored data row and reload.
"""
rows = accountability_rooms.fetch_rows_where(
guildid=self.guild.id,
start_at=self.start_time
)
self.data = rows[0] if rows else None
memberids = []
if self.data:
member_rows = accountability_members.fetch_rows_where(
slotid=self.data.slotid
)
memberids = [row.userid for row in member_rows]
self.load(memberids=memberids)
async def open(self):
"""
Open the accountability room.
Creates a new voice channel, and sends the status message.
Event logs any issues.
Adds the TimeSlot to cache.
Returns the (channelid, messageid).
"""
# Cleanup any non-existent members
for memid, mem in list(self.members.items()):
if not mem.data or not mem.member:
self.members.pop(memid)
# Calculate overwrites
overwrites = {
mem.member: self._member_overwrite
for mem in self.members.values()
}
overwrites[self.guild.default_role] = self._everyone_overwrite
# Create the channel. Log and bail if something went wrong.
if self.data and not self.channel:
try:
self.channel = await self.guild.create_voice_channel(
"Upcoming Scheduled Session",
overwrites=overwrites,
category=self.category
)
except discord.HTTPException:
GuildSettings(self.guild.id).event_log.log(
"Failed to create the scheduled session voice channel. Skipping this session.",
colour=discord.Colour.red()
)
return None
elif not self.data:
self.channel = None
# Send the inital status message. Log and bail if something goes wrong.
if not self.message:
try:
self.message = await self.lobby.send(
embed=self.open_embed
)
except discord.HTTPException:
GuildSettings(self.guild.id).event_log.log(
"Failed to post the status message in the scheduled session lobby {}.\n"
"Skipping this session.".format(self.lobby.mention),
colour=discord.Colour.red()
)
return None
if self.members:
await self.channel_notify()
return (self.channel.id if self.channel else None, self.message.id)
async def channel_notify(self, content=None):
"""
Ghost pings the session members in the lobby channel.
"""
if self.members:
content = content or "Your scheduled session has started! Please join!"
out = "{}\n\n{}".format(
content,
' '.join('<@{}>'.format(memid) for memid, mem in self.members.items() if not mem.has_attended)
)
out_msg = await self.lobby.send(out)
await out_msg.delete()
async def start(self):
"""
Start the accountability room slot.
Update the status message, and launch the DM reminder.
"""
dither = 15 * random.random()
await asyncio.sleep(dither)
if self.channel:
try:
await self.channel.edit(name="Scheduled Session Room")
await self.channel.set_permissions(self.guild.default_role, view_channel=True, connect=False)
except discord.HTTPException:
pass
asyncio.create_task(self.dm_reminder(delay=60))
try:
await self.message.edit(embed=self.status_embed)
except discord.NotFound:
try:
self.message = await self.lobby.send(
embed=self.status_embed
)
except discord.HTTPException:
self.message = None
async def dm_reminder(self, delay=60):
"""
Notifies missing members with a direct message after 1 minute.
"""
await asyncio.sleep(delay)
embed = discord.Embed(
title="The scheduled session you booked has started!",
description="Please join {}.".format(self.channel.mention),
colour=discord.Colour.orange()
).set_footer(
text=self.guild.name,
icon_url=self.guild.icon_url
)
members = (mem.member for mem in self.members.values() if not mem.has_attended)
members = (member for member in members if member)
await asyncio.gather(
*(member.send(embed=embed) for member in members),
return_exceptions=True
)
async def close(self):
"""
Delete the channel and update the status message to display a session summary.
Unloads the TimeSlot from cache.
"""
dither = 15 * random.random()
await asyncio.sleep(dither)
if self.channel:
try:
await self.channel.delete()
except discord.HTTPException:
pass
if self.message:
try:
await self.message.edit(embed=self.summary_embed)
except discord.HTTPException:
pass
# Reward members appropriately
if self.guild:
guild_settings = GuildSettings(self.guild.id)
reward = guild_settings.accountability_reward.value
if all(mem.has_attended for mem in self.members.values()):
reward += guild_settings.accountability_bonus.value
for memid in self.members:
Lion.fetch(self.guild.id, memid).addCoins(reward, bonus=True)
async def cancel(self):
"""
Cancel the slot, generally due to missing data.
Updates the message and channel if possible, removes slot from cache, and also updates data.
# TODO: Refund members
"""
if self.data:
self.data.closed_at = utc_now()
if self.channel:
try:
await self.channel.delete()
self.channel = None
except discord.HTTPException:
pass
if self.message:
try:
timestamp = int(self.start_time.timestamp())
embed = discord.Embed(
title="Session <t:{}:t> - <t:{}:t>".format(
timestamp, timestamp + 3600
),
description="Session canceled!",
colour=discord.Colour.red()
)
await self.message.edit(embed=embed)
except discord.HTTPException:
pass
async def update_status(self):
"""
Intelligently update the status message.
"""
if self.message:
if utc_now() < self.start_time:
await self.message.edit(embed=self.open_embed)
elif utc_now() < self.start_time + datetime.timedelta(hours=1):
await self.message.edit(embed=self.status_embed)
else:
await self.message.edit(embed=self.summary_embed)

View File

@@ -0,0 +1,6 @@
from .module import module
from . import data
from . import admin
from . import commands
from . import tracker

View File

@@ -0,0 +1,140 @@
import asyncio
import discord
import settings
from settings import GuildSettings, GuildSetting
from .tracker import AccountabilityGuild as AG
@GuildSettings.attach_setting
class accountability_category(settings.Channel, settings.GuildSetting):
category = "Scheduled Sessions"
attr_name = "accountability_category"
_data_column = "accountability_category"
display_name = "session_category"
desc = "Category in which to make the scheduled session rooms."
_default = None
long_desc = (
"\"Schedule session\" category channel.\n"
"Scheduled sessions will be held in voice channels created under this category."
)
_accepts = "A category channel."
_chan_type = discord.ChannelType.category
@property
def success_response(self):
if self.value:
# TODO Move this somewhere better
if self.id not in AG.cache:
AG(self.id)
return "The session category has been changed to **{}**.".format(self.value.name)
else:
return "The scheduled session system has been started in **{}**.".format(self.value.name)
else:
if self.id in AG.cache:
aguild = AG.cache.pop(self.id)
if aguild.current_slot:
asyncio.create_task(aguild.current_slot.cancel())
if aguild.upcoming_slot:
asyncio.create_task(aguild.upcoming_slot.cancel())
return "The scheduled session system has been shut down."
else:
return "The scheduled session category has been unset."
@GuildSettings.attach_setting
class accountability_lobby(settings.Channel, settings.GuildSetting):
category = "Scheduled Sessions"
attr_name = "accountability_lobby"
_data_column = attr_name
display_name = "session_lobby"
desc = "Category in which to post scheduled session notifications updates."
_default = None
long_desc = (
"Scheduled session updates will be posted here, and members will be notified in this channel.\n"
"The channel will be automatically created in the configured `session_category` if it does not exist.\n"
"Members do not need to be able to write in the channel."
)
_accepts = "Any text channel."
_chan_type = discord.ChannelType.text
async def auto_create(self):
# TODO: FUTURE
...
@GuildSettings.attach_setting
class accountability_price(settings.Integer, GuildSetting):
category = "Scheduled Sessions"
attr_name = "accountability_price"
_data_column = attr_name
display_name = "session_price"
desc = "Cost of booking a scheduled session."
_default = 500
long_desc = (
"The price of booking each one hour scheduled session slot."
)
_accepts = "An integer number of coins."
@property
def success_response(self):
return "Scheduled session slots now cost `{}` coins.".format(self.value)
@GuildSettings.attach_setting
class accountability_bonus(settings.Integer, GuildSetting):
category = "Scheduled Sessions"
attr_name = "accountability_bonus"
_data_column = attr_name
display_name = "session_bonus"
desc = "Bonus given when everyone attends a scheduled session slot."
_default = 750
long_desc = (
"The extra bonus given to each scheduled session member when everyone who booked attended the session."
)
_accepts = "An integer number of coins."
@property
def success_response(self):
return "Scheduled session members will now get `{}` coins if everyone joins.".format(self.value)
@GuildSettings.attach_setting
class accountability_reward(settings.Integer, GuildSetting):
category = "Scheduled Sessions"
attr_name = "accountability_reward"
_data_column = attr_name
display_name = "session_reward"
desc = "The individual reward given when a member attends their booked scheduled session."
_default = 500
long_desc = (
"Reward given to a member who attends a booked scheduled session."
)
_accepts = "An integer number of coins."
@property
def success_response(self):
return "Members will now get `{}` coins when they attend their scheduled session.".format(self.value)

View File

@@ -0,0 +1,596 @@
import re
import datetime
import discord
import asyncio
import contextlib
from cmdClient.checks import in_guild
from meta import client
from utils.lib import multiselect_regex, parse_ranges, prop_tabulate
from data import NOTNULL
from data.conditions import GEQ, LEQ
from .module import module
from .lib import utc_now
from .tracker import AccountabilityGuild as AGuild
from .tracker import room_lock
from .TimeSlot import SlotMember
from .data import accountability_members, accountability_member_info, accountability_rooms
hint_icon = "https://projects.iamcal.com/emoji-data/img-apple-64/1f4a1.png"
def time_format(time):
diff = (time - utc_now()).total_seconds()
if diff < 0:
diffstr = "`Right Now!!`"
elif diff < 600:
diffstr = "`Very soon!!`"
elif diff < 3600:
diffstr = "`In <1 hour `"
else:
hours = round(diff / 3600)
diffstr = "`In {:>2} hour{}`".format(hours, 's' if hours > 1 else ' ')
return "{} | <t:{:.0f}:t> - <t:{:.0f}:t>".format(
diffstr,
time.timestamp(),
time.timestamp() + 3600,
)
user_locks = {} # Map userid -> ctx
@contextlib.contextmanager
def ensure_exclusive(ctx):
"""
Cancel any existing exclusive contexts for the author.
"""
old_ctx = user_locks.pop(ctx.author.id, None)
if old_ctx:
[task.cancel() for task in old_ctx.tasks]
user_locks[ctx.author.id] = ctx
try:
yield
finally:
new_ctx = user_locks.get(ctx.author.id, None)
if new_ctx and new_ctx.msg.id == ctx.msg.id:
user_locks.pop(ctx.author.id)
@module.cmd(
name="schedule",
desc="View your schedule, and get rewarded for attending scheduled sessions!",
group="Productivity",
aliases=('rooms', 'sessions')
)
@in_guild()
async def cmd_rooms(ctx):
"""
Usage``:
{prefix}schedule
{prefix}schedule book
{prefix}schedule cancel
Description:
View your schedule with `{prefix}schedule`.
Use `{prefix}schedule book` to schedule a session at a selected time..
Use `{prefix}schedule cancel` to cancel a scheduled session.
"""
lower = ctx.args.lower()
splits = lower.split()
command = splits[0] if splits else None
if not ctx.guild_settings.accountability_category.value:
return await ctx.error_reply("The scheduled session system isn't set up!")
# First grab the sessions the member is booked in
joined_rows = accountability_member_info.select_where(
userid=ctx.author.id,
start_at=GEQ(utc_now()),
_extra="ORDER BY start_at ASC"
)
if command == 'cancel':
if not joined_rows:
return await ctx.error_reply("You have no scheduled sessions to cancel!")
# Show unbooking menu
lines = [
"`[{:>2}]` | {}".format(i, time_format(row['start_at']))
for i, row in enumerate(joined_rows)
]
out_msg = await ctx.reply(
content="Please reply with the number(s) of the sessions you want to cancel. E.g. `1, 3, 5` or `1-3, 7-8`.",
embed=discord.Embed(
title="Please choose the sessions you want to cancel.",
description='\n'.join(lines),
colour=discord.Colour.orange()
).set_footer(
text=(
"All times are in your own timezone! Hover over a time to see the date."
)
)
)
await ctx.cancellable(
out_msg,
cancel_message="Cancel menu closed, no scheduled sessions were cancelled.",
timeout=70
)
def check(msg):
valid = msg.channel == ctx.ch and msg.author == ctx.author
valid = valid and (re.search(multiselect_regex, msg.content) or msg.content.lower() == 'c')
return valid
with ensure_exclusive(ctx):
try:
message = await ctx.client.wait_for('message', check=check, timeout=60)
except asyncio.TimeoutError:
try:
await out_msg.edit(
content=None,
embed=discord.Embed(
description="Cancel menu timed out, no scheduled sessions were cancelled.",
colour=discord.Colour.red()
)
)
await out_msg.clear_reactions()
except discord.HTTPException:
pass
return
try:
await out_msg.delete()
await message.delete()
except discord.HTTPException:
pass
if message.content.lower() == 'c':
return
to_cancel = [
joined_rows[index]
for index in parse_ranges(message.content) if index < len(joined_rows)
]
if not to_cancel:
return await ctx.error_reply("No valid sessions selected for cancellation.")
elif any(row['start_at'] < utc_now() for row in to_cancel):
return await ctx.error_reply("You can't cancel a running session!")
slotids = [row['slotid'] for row in to_cancel]
async with room_lock:
deleted = accountability_members.delete_where(
userid=ctx.author.id,
slotid=slotids
)
# Handle case where the slot has already opened
# TODO: Possible race condition if they open over the hour border? Might never cancel
for row in to_cancel:
aguild = AGuild.cache.get(row['guildid'], None)
if aguild and aguild.upcoming_slot and aguild.upcoming_slot.data:
if aguild.upcoming_slot.data.slotid in slotids:
aguild.upcoming_slot.members.pop(ctx.author.id, None)
if aguild.upcoming_slot.channel:
try:
await aguild.upcoming_slot.channel.set_permissions(
ctx.author,
overwrite=None
)
except discord.HTTPException:
pass
await aguild.upcoming_slot.update_status()
break
ctx.alion.addCoins(sum(row[2] for row in deleted))
remaining = [row for row in joined_rows if row['slotid'] not in slotids]
if not remaining:
await ctx.embed_reply("Cancelled all your upcoming scheduled sessions!")
else:
next_booked_time = min(row['start_at'] for row in remaining)
if len(to_cancel) > 1:
await ctx.embed_reply(
"Cancelled `{}` upcoming sessions!\nYour next session is at <t:{:.0f}>.".format(
len(to_cancel),
next_booked_time.timestamp()
)
)
else:
await ctx.embed_reply(
"Cancelled your session at <t:{:.0f}>!\n"
"Your next session is at <t:{:.0f}>.".format(
to_cancel[0]['start_at'].timestamp(),
next_booked_time.timestamp()
)
)
elif command == 'book':
# Show booking menu
# Get attendee count
rows = accountability_member_info.select_where(
guildid=ctx.guild.id,
userid=NOTNULL,
select_columns=(
'slotid',
'start_at',
'COUNT(*) as num'
),
_extra="GROUP BY start_at, slotid"
)
attendees = {row['start_at']: row['num'] for row in rows}
attendee_pad = max((len(str(num)) for num in attendees.values()), default=1)
# Build lines
already_joined_times = set(row['start_at'] for row in joined_rows)
start_time = utc_now().replace(minute=0, second=0, microsecond=0)
times = (
start_time + datetime.timedelta(hours=n)
for n in range(1, 25)
)
times = [
time for time in times
if time not in already_joined_times and (time - utc_now()).total_seconds() > 660
]
lines = [
"`[{num:>2}]` | `{count:>{count_pad}}` attending | {time}".format(
num=i,
count=attendees.get(time, 0), count_pad=attendee_pad,
time=time_format(time),
)
for i, time in enumerate(times)
]
# TODO: Nicer embed
# TODO: Don't allow multi bookings if the member has a bad attendance rate
out_msg = await ctx.reply(
content=(
"Please reply with the number(s) of the sessions you want to book. E.g. `1, 3, 5` or `1-3, 7-8`."
),
embed=discord.Embed(
title="Please choose the sessions you want to schedule.",
description='\n'.join(lines),
colour=discord.Colour.orange()
).set_footer(
text=(
"All times are in your own timezone! Hover over a time to see the date."
)
)
)
await ctx.cancellable(
out_msg,
cancel_message="Booking menu cancelled, no sessions were booked.",
timeout=60
)
def check(msg):
valid = msg.channel == ctx.ch and msg.author == ctx.author
valid = valid and (re.search(multiselect_regex, msg.content) or msg.content.lower() == 'c')
return valid
with ensure_exclusive(ctx):
try:
message = await ctx.client.wait_for('message', check=check, timeout=30)
except asyncio.TimeoutError:
try:
await out_msg.edit(
content=None,
embed=discord.Embed(
description="Booking menu timed out, no sessions were booked.",
colour=discord.Colour.red()
)
)
await out_msg.clear_reactions()
except discord.HTTPException:
pass
return
try:
await out_msg.delete()
await message.delete()
except discord.HTTPException:
pass
if message.content.lower() == 'c':
return
to_book = [
times[index]
for index in parse_ranges(message.content) if index < len(times)
]
if not to_book:
return await ctx.error_reply("No valid sessions selected.")
elif any(time < utc_now() for time in to_book):
return await ctx.error_reply("You can't book a running session!")
cost = len(to_book) * ctx.guild_settings.accountability_price.value
if cost > ctx.alion.coins:
return await ctx.error_reply(
"Sorry, booking `{}` sessions costs `{}` coins, and you only have `{}`!".format(
len(to_book),
cost,
ctx.alion.coins
)
)
# Add the member to data, creating the row if required
slot_rows = accountability_rooms.fetch_rows_where(
guildid=ctx.guild.id,
start_at=to_book
)
slotids = [row.slotid for row in slot_rows]
to_add = set(to_book).difference((row.start_at for row in slot_rows))
if to_add:
slotids.extend(row['slotid'] for row in accountability_rooms.insert_many(
*((ctx.guild.id, start_at) for start_at in to_add),
insert_keys=('guildid', 'start_at'),
))
accountability_members.insert_many(
*((slotid, ctx.author.id, ctx.guild_settings.accountability_price.value) for slotid in slotids),
insert_keys=('slotid', 'userid', 'paid')
)
# Handle case where the slot has already opened
# TODO: Fix this, doesn't always work
aguild = AGuild.cache.get(ctx.guild.id, None)
if aguild:
if aguild.upcoming_slot and aguild.upcoming_slot.start_time in to_book:
slot = aguild.upcoming_slot
if not slot.data:
# Handle slot activation
slot._refresh()
channelid, messageid = await slot.open()
accountability_rooms.update_where(
{'channelid': channelid, 'messageid': messageid},
slotid=slot.data.slotid
)
else:
slot.members[ctx.author.id] = SlotMember(slot.data.slotid, ctx.author.id, ctx.guild)
# Also update the channel permissions
try:
await slot.channel.set_permissions(ctx.author, view_channel=True, connect=True)
except discord.HTTPException:
pass
await slot.update_status()
ctx.alion.addCoins(-cost)
# Ack purchase
embed = discord.Embed(
title="You have scheduled the following session{}!".format('s' if len(to_book) > 1 else ''),
description=(
"*Please attend all your scheduled sessions!*\n"
"*If you can't attend, cancel with* `{}schedule cancel`\n\n{}"
).format(
ctx.best_prefix,
'\n'.join(time_format(time) for time in to_book),
),
colour=discord.Colour.orange()
).set_footer(
text=(
"Use {prefix}schedule to see your current schedule.\n"
).format(prefix=ctx.best_prefix)
)
try:
await ctx.reply(
embed=embed,
reference=ctx.msg
)
except discord.NotFound:
await ctx.reply(embed=embed)
else:
# Show accountability room information for this user
# Accountability profile
# Author
# Special case for no past bookings, emphasis hint
# Hint on Bookings section for booking/cancelling as applicable
# Description has stats
# Footer says that all times are in their timezone
# TODO: attendance requirement shouldn't be retroactive! Add attended data column
# Attended `{}` out of `{}` booked (`{}%` attendance rate!)
# Attendance streak: `{}` days attended with no missed sessions!
# Add explanation for first time users
# Get all slots the member has ever booked
history = accountability_member_info.select_where(
userid=ctx.author.id,
# start_at=LEQ(utc_now() - datetime.timedelta(hours=1)),
start_at=LEQ(utc_now()),
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
_extra="ORDER BY start_at DESC"
)
if not (history or joined_rows):
# First-timer information
about = (
"You haven't scheduled any study sessions yet!\n"
"Schedule a session by typing **`{}schedule book`** and selecting "
"the hours you intend to study, "
"then attend by joining the session voice channel when it starts!\n"
"Only if everyone attends will they get the bonus of `{}` LionCoins!\n"
"Let's all do our best and keep each other accountable 🔥"
).format(
ctx.best_prefix,
ctx.guild_settings.accountability_bonus.value
)
embed = discord.Embed(
description=about,
colour=discord.Colour.orange()
)
embed.set_footer(
text="Please keep your DMs open so I can notify you when the session starts!\n",
icon_url=hint_icon
)
await ctx.reply(embed=embed)
else:
# Build description with stats
if history:
# First get the counts
attended_count = sum(row['attended'] for row in history)
total_count = len(history)
total_duration = sum(row['duration'] for row in history)
# Add current session to duration if it exists
if history[0]['last_joined_at'] and (utc_now() - history[0]['start_at']).total_seconds() < 3600:
total_duration += int((utc_now() - history[0]['last_joined_at']).total_seconds())
# Calculate the streak
timezone = ctx.alion.settings.timezone.value
streak = 0
current_streak = None
max_streak = 0
day_attended = None
date = utc_now().astimezone(timezone).replace(hour=0, minute=0, second=0, microsecond=0)
daydiff = datetime.timedelta(days=1)
i = 0
while i < len(history):
row = history[i]
i += 1
if not row['attended']:
# Not attended, streak broken
pass
elif row['start_at'] > date:
# They attended this day
day_attended = True
continue
elif day_attended is None:
# Didn't attend today, but don't break streak
day_attended = False
date -= daydiff
i -= 1
continue
elif not day_attended:
# Didn't attend the day, streak broken
date -= daydiff
i -= 1
pass
else:
# Attended the day
streak += 1
# Move window to the previous day and try the row again
date -= daydiff
day_attended = False
i -= 1
continue
max_streak = max(max_streak, streak)
if current_streak is None:
current_streak = streak
streak = 0
# Handle loop exit state, i.e. the last streak
if day_attended:
streak += 1
max_streak = max(max_streak, streak)
if current_streak is None:
current_streak = streak
# Build the stats
table = {
"Sessions": "**{}** attended out of **{}**, `{:.0f}%` attendance rate.".format(
attended_count,
total_count,
(attended_count * 100) / total_count,
),
"Time": "**{:02}:{:02}** in scheduled sessions.".format(
total_duration // 3600,
(total_duration % 3600) // 60
),
"Streak": "**{}** day{} with no missed sessions! (Longest: **{}** day{}.)".format(
current_streak,
's' if current_streak != 1 else '',
max_streak,
's' if max_streak != 1 else '',
),
}
desc = prop_tabulate(*zip(*table.items()))
else:
desc = (
"Good luck with your next session!\n"
)
# Build currently booked list
if joined_rows:
# TODO: (Future) calendar link
# Get attendee counts for currently booked sessions
rows = accountability_member_info.select_where(
slotid=[row["slotid"] for row in joined_rows],
userid=NOTNULL,
select_columns=(
'slotid',
'guildid',
'start_at',
'COUNT(*) as num'
),
_extra="GROUP BY start_at, slotid, guildid ORDER BY start_at ASC"
)
attendees = {
row['start_at']: (row['num'], row['guildid']) for row in rows
}
attendee_pad = max((len(str(num)) for num, _ in attendees.values()), default=1)
# TODO: Allow cancel to accept multiselect keys as args
show_guild = any(guildid != ctx.guild.id for _, guildid in attendees.values())
guild_map = {}
if show_guild:
for _, guildid in attendees.values():
if guildid not in guild_map:
guild = ctx.client.get_guild(guildid)
if not guild:
try:
guild = await ctx.client.fetch_guild(guildid)
except discord.HTTPException:
guild = None
guild_map[guildid] = guild
booked_list = '\n'.join(
"`{:>{}}` attendees | {} {}".format(
num,
attendee_pad,
time_format(start),
"" if not show_guild else (
"on this server" if guildid == ctx.guild.id else "in **{}**".format(
guild_map[guildid] or "Unknown"
)
)
) for start, (num, guildid) in attendees.items()
)
booked_field = (
"{}\n\n"
"*If you can't make your session, please cancel using `{}schedule cancel`!*"
).format(booked_list, ctx.best_prefix)
# Temporary footer for acclimatisation
# footer = "All times are displayed in your own timezone!"
footer = "Book another session using {}schedule book".format(ctx.best_prefix)
else:
booked_field = (
"Your schedule is empty!\n"
"Book another session using `{}schedule book`."
).format(ctx.best_prefix)
footer = "Please keep your DMs open for notifications!"
# Finally, build embed
embed = discord.Embed(
colour=discord.Colour.orange(),
description=desc,
).set_author(
name="Schedule statistics for {}".format(ctx.author.name),
icon_url=ctx.author.avatar_url
).set_footer(
text=footer,
icon_url=hint_icon
).add_field(
name="Upcoming sessions",
value=booked_field
)
# And send it!
await ctx.reply(embed=embed)
# TODO: roomadmin

View File

@@ -0,0 +1,34 @@
from data import Table, RowTable
from cachetools import TTLCache
accountability_rooms = RowTable(
'accountability_slots',
('slotid', 'channelid', 'guildid', 'start_at', 'messageid', 'closed_at'),
'slotid',
cache=TTLCache(5000, ttl=60*70),
attach_as='accountability_rooms'
)
accountability_members = RowTable(
'accountability_members',
('slotid', 'userid', 'paid', 'duration', 'last_joined_at'),
('slotid', 'userid'),
cache=TTLCache(5000, ttl=60*70)
)
accountability_member_info = Table('accountability_member_info')
accountability_open_slots = Table('accountability_open_slots')
# @accountability_member_info.save_query
# def user_streaks(userid, min_duration):
# with accountability_member_info.conn as conn:
# cursor = conn.cursor()
# with cursor:
# cursor.execute(
# """
# """
# )

View File

@@ -0,0 +1,8 @@
import datetime
def utc_now():
"""
Return the current timezone-aware utc timestamp.
"""
return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)

View File

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

View File

@@ -0,0 +1,515 @@
import asyncio
import datetime
import collections
import traceback
import logging
import discord
from typing import Dict
from discord.utils import sleep_until
from meta import client
from utils.interactive import discord_shield
from data import NULL, NOTNULL, tables
from data.conditions import LEQ, THIS_SHARD
from settings import GuildSettings
from .TimeSlot import TimeSlot
from .lib import utc_now
from .data import accountability_rooms, accountability_members
from .module import module
voice_ignore_lock = asyncio.Lock()
room_lock = asyncio.Lock()
def locker(lock):
"""
Function decorator to wrap the function in a provided Lock
"""
def decorator(func):
async def wrapped(*args, **kwargs):
async with lock:
return await func(*args, **kwargs)
return wrapped
return decorator
class AccountabilityGuild:
__slots__ = ('guildid', 'current_slot', 'upcoming_slot')
cache: Dict[int, 'AccountabilityGuild'] = {} # Map guildid -> AccountabilityGuild
def __init__(self, guildid):
self.guildid = guildid
self.current_slot = None
self.upcoming_slot = None
self.cache[guildid] = self
@property
def guild(self):
return client.get_guild(self.guildid)
@property
def guild_settings(self):
return GuildSettings(self.guildid)
def advance(self):
self.current_slot = self.upcoming_slot
self.upcoming_slot = None
async def open_next(start_time):
"""
Open all the upcoming accountability rooms, and fire channel notify.
To be executed ~5 minutes to the hour.
"""
# Pre-fetch the new slot data, also populating the table caches
room_data = accountability_rooms.fetch_rows_where(
start_at=start_time,
guildid=THIS_SHARD
)
guild_rows = {row.guildid: row for row in room_data}
member_data = accountability_members.fetch_rows_where(
slotid=[row.slotid for row in room_data]
) if room_data else []
slot_memberids = collections.defaultdict(list)
for row in member_data:
slot_memberids[row.slotid].append(row.userid)
# Open a new slot in each accountability guild
to_update = [] # Cache of slot update data to be applied at the end
for aguild in list(AccountabilityGuild.cache.values()):
guild = aguild.guild
if guild:
# Initialise next TimeSlot
slot = TimeSlot(
guild,
start_time,
data=guild_rows.get(aguild.guildid, None)
)
slot.load(memberids=slot_memberids[slot.data.slotid] if slot.data else None)
if not slot.category:
# Log and unload guild
aguild.guild_settings.event_log.log(
"The scheduled session category couldn't be found!\n"
"Shutting down the scheduled session system in this server.\n"
"To re-activate, please reconfigure `config session_category`."
)
AccountabilityGuild.cache.pop(aguild.guildid, None)
await slot.cancel()
continue
elif not slot.lobby:
# TODO: Consider putting in TimeSlot.open().. or even better in accountability_lobby.create()
# Create a new lobby
try:
channel = await guild.create_text_channel(
name="session-lobby",
category=slot.category,
reason="Automatic creation of scheduled session lobby."
)
aguild.guild_settings.accountability_lobby.value = channel
slot.lobby = channel
except discord.HTTPException:
# Event log failure and skip session
aguild.guild_settings.event_log.log(
"Failed to create the scheduled session lobby text channel.\n"
"Please set the lobby channel manually with `config`."
)
await slot.cancel()
continue
# Event log creation
aguild.guild_settings.event_log.log(
"Automatically created a scheduled session lobby channel {}.".format(channel.mention)
)
results = await slot.open()
if results is None:
# Couldn't open the channel for some reason.
# Should already have been logged in `open`.
# Skip this session
await slot.cancel()
continue
elif slot.data:
to_update.append((results[0], results[1], slot.data.slotid))
# Time slot should now be open and ready to start
aguild.upcoming_slot = slot
else:
# Unload guild from cache
AccountabilityGuild.cache.pop(aguild.guildid, None)
# Update slot data
if to_update:
accountability_rooms.update_many(
*to_update,
set_keys=('channelid', 'messageid'),
where_keys=('slotid',)
)
async def turnover():
"""
Switchover from the current accountability rooms to the next ones.
To be executed as close as possible to the hour.
"""
now = utc_now()
# Open event lock so we don't read voice channel movement
async with voice_ignore_lock:
# Update session data for completed sessions
last_slots = [
aguild.current_slot for aguild in AccountabilityGuild.cache.values()
if aguild.current_slot is not None
]
to_update = [
(mem.data.duration + int((now - mem.data.last_joined_at).total_seconds()), None, mem.slotid, mem.userid)
for slot in last_slots for mem in slot.members.values()
if mem.data and mem.data.last_joined_at
]
if to_update:
accountability_members.update_many(
*to_update,
set_keys=('duration', 'last_joined_at'),
where_keys=('slotid', 'userid'),
cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)'
)
# Close all completed rooms, update data
await asyncio.gather(*(slot.close() for slot in last_slots), return_exceptions=True)
update_slots = [slot.data.slotid for slot in last_slots if slot.data]
if update_slots:
accountability_rooms.update_where(
{'closed_at': utc_now()},
slotid=update_slots
)
# Rotate guild sessions
[aguild.advance() for aguild in AccountabilityGuild.cache.values()]
# TODO: (FUTURE) with high volume, we might want to start the sessions before moving the members.
# We could break up the session starting?
# ---------- Start next session ----------
current_slots = [
aguild.current_slot for aguild in AccountabilityGuild.cache.values()
if aguild.current_slot is not None
]
slotmap = {slot.data.slotid: slot for slot in current_slots if slot.data}
# Reload the slot members in case they cancelled from another shard
member_data = accountability_members.fetch_rows_where(
slotid=list(slotmap.keys())
) if slotmap else []
slot_memberids = {slotid: [] for slotid in slotmap}
for row in member_data:
slot_memberids[row.slotid].append(row.userid)
reload_tasks = (
slot._reload_members(memberids=slot_memberids[slotid])
for slotid, slot in slotmap.items()
)
await asyncio.gather(
*reload_tasks,
return_exceptions=True
)
# Move members of the next session over to the session channel
movement_tasks = (
mem.member.edit(
voice_channel=slot.channel,
reason="Moving to scheduled session."
)
for slot in current_slots
for mem in slot.members.values()
if mem.data and mem.member and mem.member.voice and mem.member.voice.channel != slot.channel
)
# We return exceptions here to ignore any permission issues that occur with moving members.
# It's also possible (likely) that members will move while we are moving other members
# Returning the exceptions ensures that they are explicitly ignored
await asyncio.gather(
*movement_tasks,
return_exceptions=True
)
# Update session data of all members in new channels
member_session_data = [
(0, slot.start_time, mem.slotid, mem.userid)
for slot in current_slots
for mem in slot.members.values()
if mem.data and mem.member and mem.member.voice and mem.member.voice.channel == slot.channel
]
if member_session_data:
accountability_members.update_many(
*member_session_data,
set_keys=('duration', 'last_joined_at'),
where_keys=('slotid', 'userid'),
cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)'
)
# Start all the current rooms
await asyncio.gather(
*(slot.start() for slot in current_slots),
return_exceptions=True
)
@client.add_after_event('voice_state_update')
async def room_watchdog(client, member, before, after):
"""
Update session data when a member joins or leaves an accountability room.
Ignores events that occur while `voice_ignore_lock` is held.
"""
if not voice_ignore_lock.locked() and before.channel != after.channel:
aguild = AccountabilityGuild.cache.get(member.guild.id)
if aguild and aguild.current_slot and aguild.current_slot.channel:
slot = aguild.current_slot
if member.id in slot.members:
if after.channel and after.channel.id != slot.channel.id:
# Summon them back!
asyncio.create_task(member.edit(voice_channel=slot.channel))
slot_member = slot.members[member.id]
data = slot_member.data
if before.channel and before.channel.id == slot.channel.id:
# Left accountability room
with data.batch_update():
data.duration += int((utc_now() - data.last_joined_at).total_seconds())
data.last_joined_at = None
await slot.update_status()
elif after.channel and after.channel.id == slot.channel.id:
# Joined accountability room
with data.batch_update():
data.last_joined_at = utc_now()
await slot.update_status()
async def _accountability_loop():
"""
Runloop in charge of executing the room update tasks at the correct times.
"""
# Wait until ready
while not client.is_ready():
await asyncio.sleep(0.1)
# Calculate starting next_time
# Assume the resume logic has taken care of all events/tasks before current_time
now = utc_now()
if now.minute < 55:
next_time = now.replace(minute=55, second=0, microsecond=0)
else:
next_time = now.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1)
# Executor loop
while True:
# TODO: (FUTURE) handle cases where we actually execute much late than expected
await sleep_until(next_time)
if next_time.minute == 55:
next_time = next_time + datetime.timedelta(minutes=5)
# Open next sessions
try:
async with room_lock:
await open_next(next_time)
except Exception:
# Unknown exception. Catch it so the loop doesn't die.
client.log(
"Error while opening new scheduled sessions! "
"Exception traceback follows.\n{}".format(
traceback.format_exc()
),
context="ACCOUNTABILITY_LOOP",
level=logging.ERROR
)
elif next_time.minute == 0:
# Start new sessions
try:
async with room_lock:
await turnover()
except Exception:
# Unknown exception. Catch it so the loop doesn't die.
client.log(
"Error while starting scheduled sessions! "
"Exception traceback follows.\n{}".format(
traceback.format_exc()
),
context="ACCOUNTABILITY_LOOP",
level=logging.ERROR
)
next_time = next_time + datetime.timedelta(minutes=55)
async def _accountability_system_resume():
"""
Logic for starting the accountability system from cold.
Essentially, session and state resume logic.
"""
now = utc_now()
# Fetch the open room data, only takes into account currently running sessions.
# May include sessions that were never opened, or opened but never started
# Does not include sessions that were opened that start on the next hour
open_room_data = accountability_rooms.fetch_rows_where(
closed_at=NULL,
start_at=LEQ(now),
guildid=THIS_SHARD,
_extra="ORDER BY start_at ASC"
)
if open_room_data:
# Extract member data of these rows
member_data = accountability_members.fetch_rows_where(
slotid=[row.slotid for row in open_room_data]
)
slot_members = collections.defaultdict(list)
for row in member_data:
slot_members[row.slotid].append(row)
# Filter these into expired rooms and current rooms
expired_room_data = []
current_room_data = []
for row in open_room_data:
if row.start_at + datetime.timedelta(hours=1) < now:
expired_room_data.append(row)
else:
current_room_data.append(row)
session_updates = []
# TODO URGENT: Batch room updates here
# Expire the expired rooms
for row in expired_room_data:
if row.channelid is None or row.messageid is None:
# TODO refunds here
# If the rooms were never opened, close them and skip
row.closed_at = now
else:
# If the rooms were opened and maybe started, make optimistic guesses on session data and close.
session_end = row.start_at + datetime.timedelta(hours=1)
session_updates.extend(
(mow.duration + int((session_end - mow.last_joined_at).total_seconds()),
None, mow.slotid, mow.userid)
for mow in slot_members[row.slotid] if mow.last_joined_at
)
if client.get_guild(row.guildid):
slot = TimeSlot(client.get_guild(row.guildid), row.start_at, data=row).load(
memberids=[mow.userid for mow in slot_members[row.slotid]]
)
try:
await slot.close()
except discord.HTTPException:
pass
row.closed_at = now
# Load the in-progress room data
if current_room_data:
async with voice_ignore_lock:
current_hour = now.replace(minute=0, second=0, microsecond=0)
await open_next(current_hour)
[aguild.advance() for aguild in AccountabilityGuild.cache.values()]
current_slots = [
aguild.current_slot
for aguild in AccountabilityGuild.cache.values()
if aguild.current_slot
]
session_updates.extend(
(mem.data.duration + int((now - mem.data.last_joined_at).total_seconds()),
None, mem.slotid, mem.userid)
for slot in current_slots
for mem in slot.members.values()
if mem.data.last_joined_at and mem.member not in slot.channel.members
)
session_updates.extend(
(mem.data.duration,
now, mem.slotid, mem.userid)
for slot in current_slots
for mem in slot.members.values()
if not mem.data.last_joined_at and mem.member in slot.channel.members
)
if session_updates:
accountability_members.update_many(
*session_updates,
set_keys=('duration', 'last_joined_at'),
where_keys=('slotid', 'userid'),
cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)'
)
await asyncio.gather(
*(aguild.current_slot.start()
for aguild in AccountabilityGuild.cache.values() if aguild.current_slot)
)
else:
if session_updates:
accountability_members.update_many(
*session_updates,
set_keys=('duration', 'last_joined_at'),
where_keys=('slotid', 'userid'),
cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)'
)
# If we are in the last five minutes of the hour, open new rooms.
# Note that these may already have been opened, or they may not have been.
if now.minute >= 55:
await open_next(
now.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1)
)
@module.launch_task
async def launch_accountability_system(client):
"""
Launcher for the accountability system.
Resumes saved sessions, and starts the accountability loop.
"""
# Load the AccountabilityGuild cache
guilds = tables.guild_config.fetch_rows_where(
accountability_category=NOTNULL,
guildid=THIS_SHARD
)
# Further filter out any guilds that we aren't in
[AccountabilityGuild(guild.guildid) for guild in guilds if client.get_guild(guild.guildid)]
await _accountability_system_resume()
asyncio.create_task(_accountability_loop())
async def unload_accountability(client):
"""
Save the current sessions and cancel the runloop in preparation for client shutdown.
"""
...
@client.add_after_event('member_join')
async def restore_accountability(client, member):
"""
Restore accountability channel permissions when a member rejoins the server, if applicable.
"""
aguild = AccountabilityGuild.cache.get(member.guild.id, None)
if aguild:
if aguild.current_slot and member.id in aguild.current_slot.members:
# Restore member permission for current slot
slot = aguild.current_slot
if slot.channel:
asyncio.create_task(discord_shield(
slot.channel.set_permissions(
member,
overwrite=slot._member_overwrite
)
))
if aguild.upcoming_slot and member.id in aguild.upcoming_slot.members:
slot = aguild.upcoming_slot
if slot.channel:
asyncio.create_task(discord_shield(
slot.channel.set_permissions(
member,
overwrite=slot._member_overwrite
)
))

View File

@@ -0,0 +1,4 @@
from .module import module
from . import send_cmd
from . import shop_cmds

View File

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

View File

@@ -0,0 +1,91 @@
import discord
import datetime
from cmdClient.checks import in_guild
from settings import GuildSettings
from core import Lion
from .module import module
@module.cmd(
"send",
group="Economy",
desc="Send some coins to another member."
)
@in_guild()
async def cmd_send(ctx):
"""
Usage``:
{prefix}send <user mention> <amount>
Description:
Send the given number of coins to the mentioned user.
Example:
{prefix}send {ctx.author.mention} 1000000
"""
# Extract target and amount
# Handle a slightly more flexible input than stated
splits = ctx.args.split()
digits = [split.isdigit() for split in splits[:2]]
mentions = ctx.msg.mentions
if len(splits) < 2 or not any(digits) or not (all(digits) or mentions):
return await _send_usage(ctx)
if all(digits):
# Both are digits, hopefully one is a member id, and one is an amount.
target, amount = ctx.guild.get_member(int(splits[0])), int(splits[1])
if not target:
amount, target = int(splits[0]), ctx.guild.get_member(int(splits[1]))
if not target:
return await _send_usage(ctx)
elif digits[0]:
amount, target = int(splits[0]), mentions[0]
elif digits[1]:
target, amount = mentions[0], int(splits[1])
# Fetch the associated lions
target_lion = Lion.fetch(ctx.guild.id, target.id)
source_lion = Lion.fetch(ctx.guild.id, ctx.author.id)
# Check sanity conditions
if amount > source_lion.coins:
return await ctx.error_reply(
"Sorry {}, you do not have enough LionCoins to do that.".format(ctx.author.mention)
)
if target == ctx.author:
return await ctx.embed_reply("What is this, tax evasion?")
if target == ctx.client.user:
return await ctx.embed_reply("Thanks, but Ari looks after all my needs!")
if target.bot:
return await ctx.embed_reply("We are still waiting for {} to open an account.".format(target.mention))
# Finally, send the amount and the ack message
target_lion.addCoins(amount)
source_lion.addCoins(-amount)
embed = discord.Embed(
title="Funds transferred",
description="You have sent **{}** LionCoins to {}!".format(amount, target.mention),
colour=discord.Colour.orange(),
timestamp=datetime.datetime.utcnow()
).set_footer(text=str(ctx.author), icon_url=ctx.author.avatar_url)
await ctx.reply(embed=embed, reference=ctx.msg)
GuildSettings(ctx.guild.id).event_log.log(
"{} sent {} `{}` LionCoins.".format(
ctx.author.mention,
target.mention,
amount
),
title="Funds transferred"
)
async def _send_usage(ctx):
return await ctx.error_reply(
"**Usage:** `{prefix}send <mention> <amount>`\n"
"**Example:** {prefix}send {ctx.author.mention} 1000000".format(
prefix=ctx.best_prefix,
ctx=ctx
)
)

View File

@@ -0,0 +1,246 @@
import asyncio
import discord
from collections import defaultdict
from cmdClient.checks import in_guild
from .module import module
from .shop_core import ShopItem, ShopItemType, ColourRole
from wards import is_guild_admin
class ShopSession:
__slots__ = (
'key', 'response',
'_event', '_task'
)
_sessions = {}
def __init__(self, userid, channelid):
# Build unique context key for shop session
self.key = (userid, channelid)
self.response = None
# Cancel any existing sessions
if self.key in self._sessions:
self._sessions[self.key].cancel()
self._event = asyncio.Event()
self._task = None
# Add self to the session list
self._sessions[self.key] = self
@classmethod
def get(cls, userid, channelid) -> 'ShopSession':
"""
Get a ShopSession matching the given key, if it exists.
Otherwise, returns None.
"""
return cls._sessions.get((userid, channelid), None)
async def wait(self, timeout=None):
"""
Wait for a buy response. Return the set response or raise an appropriate exception.
"""
self._task = asyncio.create_task(self._event.wait())
try:
await asyncio.wait_for(self._task, timeout=timeout)
except asyncio.CancelledError:
# Session was cancelled, likely due to creation of a new session
raise
except asyncio.TimeoutError:
# Session timed out, likely due to reaching the timeout
raise
finally:
if self._sessions.get(self.key, None) == self:
self._sessions.pop(self.key, None)
return self.response
def set(self, response):
"""
Set response.
"""
self.response = response
self._event.set()
def cancel(self):
"""
Cancel a session.
"""
if self._task:
if self._sessions.get(self.key, None) == self:
self._sessions.pop(self.key, None)
self._task.cancel()
else:
raise ValueError("Cancelling a ShopSession that is already completed!")
@module.cmd(
'shop',
group="Economy",
desc="Open the guild shop.",
flags=('add==', 'remove==', 'clear')
)
@in_guild()
async def cmd_shop(ctx, flags):
"""
Usage``:
{prefix}shop
{prefix}shop --add <price>, <item>
{prefix}shop --remove
{prefix}shop --remove itemid, itemid, ...
{prefix}shop --remove itemname, itemname, ...
{prefix}shop --clear
Description:
Opens the guild shop. Visible items may be bought using `{prefix}buy`.
*Modifying the guild shop requires administrator permissions.*
"""
# TODO: (FUTURE) Register session (and cancel previous sessions) so we can track for buy
# Whether we are modifying the shop
modifying = any(value is not False for value in flags.values())
if modifying and not is_guild_admin(ctx.author):
return await ctx.error_reply(
"You need to be a guild admin to modify the shop!"
)
# Fetch all purchasable elements, this also populates the cache
shop_items = ShopItem.fetch_where(guildid=ctx.guild.id, deleted=False, purchasable=True)
if not shop_items and not modifying:
# TODO: (FUTURE) Add tip for guild admins about setting up
return await ctx.error_reply(
"Nothing to buy! Please come back later."
)
# Categorise items
item_cats = defaultdict(list)
for item in shop_items:
item_cats[item.item_type].append(item)
# If there is more than one category, ask for which category they want
# All FUTURE TODO stuff, to be refactored into a shop widget
# item_type = None
# if ctx.args:
# # Assume category has been entered
# ...
# elif len(item_cats) > 1:
# # TODO: Show selection menu
# item_type = ...
# ...
# else:
# # Pick the next one automatically
# item_type = next(iter(item_cats))
item_type = ShopItemType.COLOUR_ROLE
item_class = ColourRole
if item_type is not None:
items = [item for item in item_cats[item_type]]
embeds = item_class.cat_shop_embeds(
ctx.guild.id,
[item.itemid for item in items]
)
if modifying:
if flags['add']:
await item_class.parse_add(ctx, flags['add'])
elif flags['remove'] is not False:
await item_class.parse_remove(ctx, flags['remove'], items)
elif flags['clear']:
await item_class.parse_clear(ctx)
return
# Present shop pages
out_msg = await ctx.pager(embeds, add_cancel=True)
await ctx.cancellable(out_msg, add_reaction=False)
while True:
try:
response = await ShopSession(ctx.author.id, ctx.ch.id).wait(timeout=300)
except asyncio.CancelledError:
# User opened a new session
break
except asyncio.TimeoutError:
# Session timed out. Close the shop.
# TODO: (FUTURE) time out shop session by removing hint.
try:
embed = discord.Embed(
colour=discord.Colour.red(),
description="Shop closed! (Session timed out.)"
)
await out_msg.edit(
embed=embed
)
except discord.HTTPException:
pass
break
# Selection was made
# Sanity checks
# TODO: (FUTURE) Handle more flexible ways of selecting items
if int(response.args) >= len(items):
await response.error_reply(
"No item with this number exists! Please try again."
)
continue
item = items[int(response.args)]
if item.price > ctx.alion.coins:
await response.error_reply(
"Sorry, {} costs `{}` LionCoins, but you only have `{}`!".format(
item.display_name,
item.price,
ctx.alion.coins
)
)
continue
# Run the selection and keep the shop open in case they want to buy more.
# TODO: (FUTURE) The item may no longer exist
success = await item.buy(response)
if success and not item.allow_multi_select:
try:
await out_msg.delete()
except discord.HTTPException:
pass
break
@module.cmd(
'buy',
group="Hidden",
desc="Buy an item from the guild shop."
)
@in_guild()
async def cmd_buy(ctx):
"""
Usage``:
{prefix}buy <number>
Description:
Only usable while you have a shop open (see `{prefix}shop`).
Buys the selected item from the shop.
"""
# Check relevant session exists
session = ShopSession.get(ctx.author.id, ctx.ch.id)
if session is None:
return await ctx.error_reply(
"No shop open, nothing to buy!\n"
"Please open a shop with `{prefix}shop` first.".format(prefix=ctx.best_prefix)
)
# Check input is an integer
if not ctx.args.isdigit():
return await ctx.error_reply(
"**Usage:** `{prefix}buy <number>`, for example `{prefix}buy 1`.".format(prefix=ctx.best_prefix)
)
# Pass context back to session
session.set(ctx)
# TODO: (FUTURE) Buy command short-circuiting the shop command and acting on shop sessions
# TODO: (FUTURE) Inventory command showing the user's purchases

View File

@@ -0,0 +1,377 @@
import re
import asyncio
from typing import List
import datetime
import discord
from cmdClient.lib import SafeCancellation
from meta import client
from utils.lib import multiselect_regex, parse_ranges
from .ShopItem import ShopItem, ShopItemType
from .data import shop_items, shop_item_info, colour_roles
@ShopItem.register_item_class
class ColourRole(ShopItem):
item_type = ShopItemType.COLOUR_ROLE
allow_multi_select = False
buy_hint = (
"Buy a colour by typing, e.g.,`{prefix}buy 0`.\n"
"**Note: You may only own one colour at a time!**"
).format(prefix=client.prefix)
@property
def display_name(self):
return "<@&{}>".format(self.data.roleid)
@property
def roleid(self) -> int:
return self.data.roleid
@property
def role(self) -> discord.Role:
return self.guild.get_role(self.roleid)
@classmethod
async def create(cls, guildid, price, roleid, **kwargs):
"""
Create a new ColourRole item.
"""
shop_row = shop_items.insert(
guildid=guildid,
item_type=cls.item_type,
price=price
)
colour_roles.insert(
itemid=shop_row['itemid'],
roleid=roleid
)
return cls(shop_row['itemid'])
# Shop interface
@classmethod
def _cat_shop_embed_items(cls, items: List['ColourRole'], hint: str = buy_hint, **kwargs) -> List[discord.Embed]:
"""
Embed a list of items specifically for displaying in the shop.
Subclasses will usually extend or override this, if only to add metadata.
"""
if items:
# TODO: prefix = items[0].guild_settings.prefix.value
embeds = cls._cat_embed_items(items, **kwargs)
for embed in embeds:
embed.title = "{} shop!".format(cls.item_type.desc)
embed.description += "\n\n" + hint
else:
embed = discord.Embed(
title="{} shop!".format(cls.item_type.desc),
description="No colours available at the moment! Please come back later."
)
embeds = [embed]
return embeds
async def buy(self, ctx):
"""
Action when a member buys a color role.
Uses Discord as the source of truth (rather than the future inventory).
Removes any existing colour roles, and adds the purchased role.
Also notifies the user and logs the transaction.
"""
# TODO: Exclusivity should be handled at a higher level
# TODO: All sorts of error handling
member = ctx.author
# Fetch the set colour roles
colour_rows = shop_item_info.fetch_rows_where(
guildid=self.guildid,
item_type=self.item_type
)
roleids = (row.roleid for row in colour_rows)
roles = (self.guild.get_role(roleid) for roleid in roleids)
roles = set(role for role in roles if role)
# Compute the roles to add and remove
to_add = self.guild.get_role(self.roleid)
member_has = roles.intersection(member.roles)
if to_add in member_has:
await ctx.error_reply("You already have this colour!")
return False
to_remove = list(member_has)
# Role operations
if to_add:
try:
await member.add_roles(to_add, reason="Updating purchased colour role")
except discord.HTTPException:
# TODO: Add to log
to_add = None
pass
if to_remove:
try:
await member.remove_roles(*to_remove, reason="Updating purchased colour role")
except discord.HTTPException:
# TODO: Add to log
pass
# Only charge the member if everything went well
if to_add:
ctx.alion.addCoins(-self.price)
# Build strings for logging and response
desc = None # Description of reply message to the member
log_str = None # Description of event log message
log_error = False # Whether to log an error
if to_add:
if to_remove:
if len(to_remove) > 1:
rem_str = ', '.join(role.mention for role in to_remove[:-1]) + 'and' + to_remove[-1].mention
else:
rem_str = to_remove[0].mention
desc = "You have exchanged {} for {}. Enjoy!".format(rem_str, to_add.mention)
log_str = "{} exchanged {} for {}.".format(
member.mention,
rem_str,
to_add.mention
)
else:
desc = "You have bought {}. Enjoy!".format(to_add.mention)
log_str = "{} bought {}.".format(member.mention, to_add.mention)
else:
desc = (
"Something went wrong! Please try again later.\n"
"(I don't have the server permissions to give this role to you!)"
)
log_str = (
"{} bought `{}`, but I couldn't add the role!\n"
"Please ensure that I have permission to manage this role.\n"
"(I need to have the `manage_roles` permission, "
"and my top role needs to be above the colour roles.)"
).format(member.mention, self.roleid)
log_error = True
# Build and send embeds
reply_embed = discord.Embed(
colour=to_add.colour if to_add else discord.Colour.red(),
description=desc,
timestamp=datetime.datetime.utcnow()
)
if to_add:
reply_embed.set_footer(
text="New Balance: {} LC".format(ctx.alion.coins)
)
log_embed = discord.Embed(
title="Colour Role Purchased" if not log_error else "Error purchasing colour role.",
colour=discord.Colour.red() if log_error else discord.Colour.orange(),
description=log_str
)
try:
await ctx.reply(embed=reply_embed)
except discord.HTTPException:
pass
event_log = ctx.guild_settings.event_log.value
if event_log:
try:
await event_log.send(embed=log_embed)
except discord.HTTPException:
pass
if not to_add:
return False
return True
# Shop admin interface
@classmethod
async def parse_add(cls, ctx, args):
"""
Parse a request to add colour roles.
Syntax: `<price>, <role>`, with different lines treated as different entries.
Assumes the author is an admin.
"""
if not args:
raise SafeCancellation("No arguments given, nothing to do!")
lines = args.splitlines()
to_add = [] # List of (price, role) tuples to add
for line in lines:
# Syntax: <price>, <role>
splits = line.split(',')
splits = [split.strip() for split in splits]
if len(splits) < 2 or not splits[0].isdigit():
raise SafeCancellation("**Syntax:** `--add <price>, <role>`")
price = int(splits[0])
role = await ctx.find_role(splits[1], create=True, interactive=True, allow_notfound=False)
to_add.append((price, role))
# Add the roles to data
for price, role in to_add:
# TODO: Batch update would be best
await cls.create(ctx.guild.id, price, role.id)
# Report back
if len(to_add) > 1:
await ctx.reply(
embed=discord.Embed(
title="Shop Updated",
description="Added `{}` new colours to the shop!".format(len(to_add))
)
)
else:
await ctx.reply(
embed=discord.Embed(
title="Shop Updated",
description="Added {} to the shop for `{}` coins.".format(to_add[0][1].mention, to_add[0][0])
)
)
@classmethod
async def parse_remove(cls, ctx, args, items):
"""
Parse a request to remove colour roles.
Syntax: `<ids>` or `<command separated roles>`
Assumes the author is an admin.
"""
if not items:
raise SafeCancellation("Colour shop is empty, nothing to delete!")
to_delete = []
if args:
if re.search(multiselect_regex, args):
# ids were selected
indexes = parse_ranges(args)
to_delete = [items[index] for index in indexes if index < len(items)]
if not to_delete:
# Assume comma separated list of roles
splits = args.split(',')
splits = [split.strip() for split in splits]
available_roles = (item.role for item in items)
available_roles = [role for role in available_roles if role]
roles = [
await ctx.find_role(rolestr, collection=available_roles, interactive=True, allow_notfound=False)
for rolestr in splits
]
roleids = set(role.id for role in roles)
to_delete = [item for item in items if item.roleid in roleids]
else:
# Interactive multi-selector
itemids = [item.itemid for item in items]
embeds = cls.cat_shop_embeds(
ctx.guild.id,
itemids,
hint=("Please select colour(s) to remove, or `c` to cancel.\n"
"(Respond with e.g. `1, 2, 3` or `1-3`.)")
)
out_msg = await ctx.pager(embeds)
def check(msg):
valid = msg.channel == ctx.ch and msg.author == ctx.author
valid = valid and (re.search(multiselect_regex, msg.content) or msg.content.lower() == 'c')
return valid
try:
message = await ctx.client.wait_for('message', check=check, timeout=60)
except asyncio.TimeoutError:
await out_msg.delete()
await ctx.error_reply("Session timed out. No colour roles were removed.")
return
try:
await out_msg.delete()
await message.delete()
except discord.HTTPException:
pass
if message.content.lower() == 'c':
return
to_delete = [
items[index]
for index in parse_ranges(message.content) if index < len(items)
]
if not to_delete:
raise SafeCancellation("Nothing to delete!")
# Build an ack string before we delete
rolestr = to_delete[0].role.mention if to_delete[0].role else "`{}`".format(to_delete[0].roleid)
# Delete the items
shop_items.delete_where(itemid=[item.itemid for item in to_delete])
# Update the info cache
[shop_item_info.row_cache.pop(item.itemid, None) for item in to_delete]
# Ack and log
if len(to_delete) > 1:
try:
await ctx.reply(
embed=discord.Embed(
title="Colour Roles removed",
description="You have removed `{}` colour roles.".format(len(to_delete)),
colour=discord.Colour.orange()
)
)
except discord.HTTPException:
pass
event_log = ctx.guild_settings.event_log.value
if event_log:
try:
await event_log.send(
embed=discord.Embed(
title="Colour Roles deleted",
description="{} removed `{}` colour roles from the shop.".format(
ctx.author.mention,
len(to_delete)
),
timestamp=datetime.datetime.utcnow()
)
)
except discord.HTTPException:
pass
else:
try:
await ctx.reply(
embed=discord.Embed(
title="Colour Role removed",
description="You have removed the colour role {}.".format(rolestr),
colour=discord.Colour.orange()
)
)
except discord.HTTPException:
pass
event_log = ctx.guild_settings.event_log.value
if event_log:
try:
await event_log.send(
embed=discord.Embed(
title="Colour Role deleted",
description="{} removed the colour role {} from the shop.".format(
ctx.author.mention,
rolestr
),
timestamp=datetime.datetime.utcnow()
)
)
except discord.HTTPException:
pass
@classmethod
async def parse_clear(cls, ctx):
"""
Parse a request to clear colour roles.
Assumes the author is an admin.
"""
if await ctx.ask("Are you sure you want to remove all colour roles from the shop?"):
shop_items.delete_where(guildid=ctx.guild.id, item_type=cls.item_type)
await ctx.reply("All colour roles removed from the shop.")
ctx.guild_settings.event_log.log("{} cleared the colour role shop.".format(ctx.author.mention))

View File

@@ -0,0 +1,265 @@
import discord
import datetime
from typing import List
from meta import client
from utils.lib import FieldEnum
from data import Row
from settings import GuildSettings
from .data import shop_items, shop_item_info
class ShopItemType(FieldEnum):
COLOUR_ROLE = 'COLOUR_ROLE', 'Colour'
class ShopItem:
"""
Abstract base class representing an item in a guild shop.
"""
__slots__ = ('itemid', '_guild')
# Mapping of item types to class handlers
_item_classes = {} # ShopItemType -> ShopItem subclass
# Item type handled by the current subclass
item_type = None # type: ShopItemType
# Format string to use for each item of this type in the shop embed
shop_fmt = "`[{num:<{num_len}}]` | `{item.price:<{price_len}} LC` {item.display_name}"
# Shop input modifiers
allow_multi_select = True
buy_hint = None
def __init__(self, itemid, *args, **kwargs):
self.itemid = itemid # itemid in shop_items representing this info
self._guild = None # cached Guild
# Meta
@classmethod
def register_item_class(cls, itemcls):
"""
Decorator to register a class as a handler for a given item type.
The item type must be set as the `item_type` class attribute.
"""
cls._item_classes[itemcls.item_type] = itemcls
return itemcls
@classmethod
async def create(cls, guildid, price, *args, **kwargs):
"""
Create a new ShopItem of this type.
Must be implemented by each item class.
"""
raise NotImplementedError
@classmethod
def fetch_where(cls, **kwargs):
"""
Fetch ShopItems matching the given conditions.
Automatically filters by `item_type` if set in class, and not provided.
"""
if cls.item_type is not None and 'item_type' not in kwargs:
kwargs['item_type'] = cls.item_type
rows = shop_item_info.fetch_rows_where(**kwargs)
return [
cls._item_classes[ShopItemType(row.item_type)](row.itemid)
for row in rows
]
@classmethod
def fetch(cls, itemid):
"""
Fetch a single ShopItem by itemid.
"""
row = shop_item_info.fetch(itemid)
if row:
return cls._item_classes[ShopItemType(row.item_type)](row.itemid)
else:
return None
# Data and transparent data properties
@property
def data(self) -> Row:
"""
Return the cached data row for this item.
This is not guaranteed to be up to date.
This is also not guaranteed to keep existing during a session.
"""
return shop_item_info.fetch(self.itemid)
@property
def guildid(self) -> int:
return self.data.guildid
@property
def price(self) -> int:
return self.data.price
@property
def purchasable(self) -> bool:
return self.data.purchasable
# Computed properties
@property
def guild(self) -> discord.Guild:
if not self._guild:
self._guild = client.get_guild(self.guildid)
return self._guild
@property
def guild_settings(self) -> GuildSettings:
return GuildSettings(self.guildid)
# Display properties
@property
def display_name(self) -> str:
"""
Short name to display after purchasing the item, and by default in the shop.
"""
raise NotImplementedError
# Data manipulation methods
def refresh(self) -> Row:
"""
Refresh the stored data row.
"""
shop_item_info.row_cache.pop(self.itemid, None)
return self.data
def _update(self, **kwargs):
"""
Updates the data with the provided kwargs.
Subclasses are expected to override this is they provide their own updatable data.
This method does *not* refresh the data row. This is expect to be handled by `update`.
"""
handled = ('price', 'purchasable')
update = {key: kwargs[key] for key in handled}
if update:
shop_items.update_where(
update,
itemid=self.itemid
)
async def update(self, **kwargs):
"""
Update the shop item with the given kwargs.
"""
self._update()
self.refresh()
# Formatting
@classmethod
def _cat_embed_items(cls, items: List['ShopItem'], blocksize: int = 20,
fmt: str = shop_fmt, **kwargs) -> List[discord.Embed]:
"""
Build a list of embeds for the current item type from a list of items.
These embeds may be used anywhere multiple items may be shown,
including confirmations and shop pages.
Subclasses may extend or override.
"""
embeds = []
if items:
# Cut into blocks
item_blocks = [items[i:i+blocksize] for i in range(0, len(items), blocksize)]
for i, item_block in enumerate(item_blocks):
# Compute lengths
num_len = len(str((i * blocksize + len(item_block) - 1)))
max_price = max(item.price for item in item_block)
price_len = len(str(max_price))
# Format items
string_block = '\n'.join(
fmt.format(
item=item,
num=i * blocksize + j,
num_len=num_len,
price_len=price_len
) for j, item in enumerate(item_block)
)
# Build embed
embed = discord.Embed(
description=string_block,
timestamp=datetime.datetime.utcnow()
)
if len(item_blocks) > 1:
embed.set_footer(text="Page {}/{}".format(i+1, len(item_blocks)))
embeds.append(embed)
else:
# Empty shop case, should generally be avoided
embed = discord.Embed(
description="Nothing to show!"
)
embeds.append(embed)
return embeds
# Shop interface
@classmethod
def _cat_shop_embed_items(cls, items: List['ShopItem'], **kwargs) -> List[discord.Embed]:
"""
Embed a list of items specifically for displaying in the shop.
Subclasses will usually extend or override this, if only to add metadata.
"""
if items:
# TODO: prefix = items[0].guild_settings.prefix.value
prefix = client.prefix
embeds = cls._cat_embed_items(items, **kwargs)
for embed in embeds:
embed.title = "{} shop!".format(cls.item_type.desc)
embed.description = "{}\n\n{}".format(
embed.description,
"Buy items with `{prefix}buy <numbers>`, e.g. `{prefix}buy 1, 2, 3`.".format(
prefix=prefix
)
)
else:
embed = discord.Embed(
title="{} shop!".format(cls.item_type.desc),
description="This shop is empty! Please come back later."
)
embeds = [embed]
return embeds
@classmethod
def cat_shop_embeds(cls, guildid: int, itemids: List[int] = None, **kwargs) -> List[discord.Embed]:
"""
Format the items of this type (i.e. this category) as one or more embeds.
Subclasses may extend or override.
"""
if itemids is None:
# Get itemids if not provided
# TODO: Not using the row cache here, make sure we don't need an extended caching form
rows = shop_item_info.fetch_rows_where(
guildid=guildid,
item_type=cls.item_type,
purchasable=True,
deleted=False
)
itemids = [row.itemid for row in rows]
elif not all(itemid in shop_item_info.row_cache for itemid in itemids):
# Ensure cache is populated
shop_item_info.fetch_rows_where(itemid=itemids)
return cls._cat_shop_embed_items([cls(itemid) for itemid in itemids], **kwargs)
async def buy(self, ctx):
"""
Action to trigger when a member buys this item.
"""
raise NotImplementedError
# Shop admin interface
@classmethod
async def parse_new(self, ctx):
"""
Parse new shop items.
"""
raise NotImplementedError

View File

@@ -0,0 +1,4 @@
from . import data
from .ShopItem import ShopItem, ShopItemType
from .ColourRole import ColourRole

View File

@@ -0,0 +1,19 @@
from cachetools import LRUCache
from data import Table, RowTable
shop_items = Table('shop_items')
colour_roles = Table('shop_items_colour_roles', attach_as='colour_roles')
shop_item_info = RowTable(
'shop_item_info',
('itemid',
'guildid', 'item_type', 'price', 'purchasable', 'deleted', 'created_at',
'roleid', # Colour roles
),
'itemid',
cache=LRUCache(1000)
)

View File

@@ -0,0 +1,7 @@
from .module import module
from . import guild_config
from . import statreset
from . import new_members
from . import reaction_roles
from . import economy

View File

@@ -0,0 +1,3 @@
from ..module import module
from . import set_coins

View File

@@ -0,0 +1,104 @@
import discord
import datetime
from wards import guild_admin
from settings import GuildSettings
from core import Lion
from ..module import module
POSTGRES_INT_MAX = 2147483647
@module.cmd(
"set_coins",
group="Guild Admin",
desc="Set coins on a member."
)
@guild_admin()
async def cmd_set(ctx):
"""
Usage``:
{prefix}set_coins <user mention> <amount>
Description:
Sets the given number of coins on the mentioned user.
If a number greater than 0 is mentioned, will add coins.
If a number less than 0 is mentioned, will remove coins.
Note: LionCoins on a member cannot be negative.
Example:
{prefix}set_coins {ctx.author.mention} 100
{prefix}set_coins {ctx.author.mention} -100
"""
# Extract target and amount
# Handle a slightly more flexible input than stated
splits = ctx.args.split()
digits = [isNumber(split) for split in splits[:2]]
mentions = ctx.msg.mentions
if len(splits) < 2 or not any(digits) or not (all(digits) or mentions):
return await _send_usage(ctx)
if all(digits):
# Both are digits, hopefully one is a member id, and one is an amount.
target, amount = ctx.guild.get_member(int(splits[0])), int(splits[1])
if not target:
amount, target = int(splits[0]), ctx.guild.get_member(int(splits[1]))
if not target:
return await _send_usage(ctx)
elif digits[0]:
amount, target = int(splits[0]), mentions[0]
elif digits[1]:
target, amount = mentions[0], int(splits[1])
# Fetch the associated lion
target_lion = Lion.fetch(ctx.guild.id, target.id)
# Check sanity conditions
if target == ctx.client.user:
return await ctx.embed_reply("Thanks, but Ari looks after all my needs!")
if target.bot:
return await ctx.embed_reply("We are still waiting for {} to open an account.".format(target.mention))
# Finally, send the amount and the ack message
# Postgres `coins` column is `integer`, sanity check postgres int limits - which are smalled than python int range
target_coins_to_set = target_lion.coins + amount
if target_coins_to_set >= 0 and target_coins_to_set <= POSTGRES_INT_MAX:
target_lion.addCoins(amount)
elif target_coins_to_set < 0:
target_coins_to_set = -target_lion.coins # Coins cannot go -ve, cap to 0
target_lion.addCoins(target_coins_to_set)
target_coins_to_set = 0
else:
return await ctx.embed_reply("Member coins cannot be more than {}".format(POSTGRES_INT_MAX))
embed = discord.Embed(
title="Funds Set",
description="You have set LionCoins on {} to **{}**!".format(target.mention,target_coins_to_set),
colour=discord.Colour.orange(),
timestamp=datetime.datetime.utcnow()
).set_footer(text=str(ctx.author), icon_url=ctx.author.avatar_url)
await ctx.reply(embed=embed, reference=ctx.msg)
GuildSettings(ctx.guild.id).event_log.log(
"{} set {}'s LionCoins to`{}`.".format(
ctx.author.mention,
target.mention,
target_coins_to_set
),
title="Funds Set"
)
def isNumber(var):
try:
return isinstance(int(var), int)
except:
return False
async def _send_usage(ctx):
return await ctx.error_reply(
"**Usage:** `{prefix}set_coins <mention> <amount>`\n"
"**Example:**\n"
" {prefix}set_coins {ctx.author.mention} 100\n"
" {prefix}set_coins {ctx.author.mention} -100".format(
prefix=ctx.best_prefix,
ctx=ctx
)
)

View File

@@ -0,0 +1,163 @@
import difflib
import discord
from cmdClient.lib import SafeCancellation
from wards import guild_admin, guild_moderator
from settings import UserInputError, GuildSettings
from utils.lib import prop_tabulate
import utils.ctx_addons # noqa
from .module import module
# Pages of configuration categories to display
cat_pages = {
'Administration': ('Meta', 'Guild Roles', 'New Members'),
'Moderation': ('Moderation', 'Video Channels'),
'Productivity': ('Study Tracking', 'TODO List', 'Workout'),
'Study Rooms': ('Rented Rooms', 'Scheduled Sessions'),
}
# Descriptions of each configuration category
descriptions = {
}
@module.cmd("config",
desc="View and modify the server settings.",
flags=('add', 'remove'),
group="Guild Configuration")
@guild_moderator()
async def cmd_config(ctx, flags):
"""
Usage``:
{prefix}config
{prefix}config info
{prefix}config <setting>
{prefix}config <setting> <value>
Description:
Display the server configuration panel, and view/modify the server settings.
Use `{prefix}config` to see the settings with their current values, or `{prefix}config info` to \
show brief descriptions instead.
Use `{prefix}config <setting>` (e.g. `{prefix}config event_log`) to view a more detailed description for each setting, \
including the possible values.
Finally, use `{prefix}config <setting> <value>` to set the setting to the given value.
To unset a setting, or set it to the default, use `{prefix}config <setting> None`.
Additional usage for settings which accept a list of values:
`{prefix}config <setting> <value1>, <value2>, ...`
`{prefix}config <setting> --add <value1>, <value2>, ...`
`{prefix}config <setting> --remove <value1>, <value2>, ...`
Note that the first form *overwrites* the setting completely,\
while the second two will only *add* and *remove* values, respectively.
Examples``:
{prefix}config event_log
{prefix}config event_log {ctx.ch.name}
{prefix}config autoroles Member, Level 0, Level 10
{prefix}config autoroles --remove Level 10
"""
# Cache and map some info for faster access
setting_displaynames = {setting.display_name.lower(): setting for setting in GuildSettings.settings.values()}
if not ctx.args or ctx.args.lower() in ('info', 'help'):
# Fill the setting cats
cats = {}
for setting in GuildSettings.settings.values():
cat = cats.get(setting.category, [])
cat.append(setting)
cats[setting.category] = cat
# Format the cats
sections = {}
for catname, cat in cats.items():
catprops = {
setting.display_name: setting.get(ctx.guild.id).summary if not ctx.args else setting.desc
for setting in cat
}
# TODO: Add cat description here
sections[catname] = prop_tabulate(*zip(*catprops.items()))
# Put the cats on the correct pages
pages = []
for page_name, cat_names in cat_pages.items():
page = {
cat_name: sections[cat_name] for cat_name in cat_names if cat_name in sections
}
if page:
embed = discord.Embed(
colour=discord.Colour.orange(),
title=page_name,
description=(
"View brief setting descriptions with `{prefix}config info`.\n"
"Use e.g. `{prefix}config event_log` to see more details.\n"
"Modify a setting with e.g. `{prefix}config event_log {ctx.ch.name}`.\n"
"See the [Online Tutorial]({tutorial}) for a complete setup guide.".format(
prefix=ctx.best_prefix,
ctx=ctx,
tutorial="https://discord.studylions.com/tutorial"
)
)
)
for name, value in page.items():
embed.add_field(name=name, value=value, inline=False)
pages.append(embed)
if len(pages) > 1:
[
embed.set_footer(text="Page {} of {}".format(i+1, len(pages)))
for i, embed in enumerate(pages)
]
await ctx.pager(pages)
elif pages:
await ctx.reply(embed=pages[0])
else:
await ctx.reply("No configuration options set up yet!")
else:
# Some args were given
parts = ctx.args.split(maxsplit=1)
name = parts[0]
setting = setting_displaynames.get(name.lower(), None)
if setting is None:
matches = difflib.get_close_matches(name, setting_displaynames.keys(), n=2)
match = "`{}`".format('` or `'.join(matches)) if matches else None
return await ctx.error_reply(
"Couldn't find a setting called `{}`!\n"
"{}"
"Use `{}config info` to see all the server settings.".format(
name,
"Maybe you meant {}?\n".format(match) if match else "",
ctx.best_prefix
)
)
if len(parts) == 1 and not ctx.msg.attachments:
# config <setting>
# View config embed for provided setting
await setting.get(ctx.guild.id).widget(ctx, flags=flags)
else:
# config <setting> <value>
# Ignoring the write ward currently and just enforcing admin
# Check the write ward
# if not await setting.write_ward.run(ctx):
# raise SafeCancellation(setting.write_ward.msg)
if not await guild_admin.run(ctx):
raise SafeCancellation("You need to be a server admin to modify settings!")
# Attempt to set config setting
try:
parsed = await setting.parse(ctx.guild.id, ctx, parts[1] if len(parts) > 1 else '')
parsed.write(add_only=flags['add'], remove_only=flags['remove'])
except UserInputError as e:
await ctx.reply(embed=discord.Embed(
description="{} {}".format('', e.msg),
colour=discord.Colour.red()
))
else:
await ctx.reply(embed=discord.Embed(
description="{} {}".format('', setting.get(ctx.guild.id).success_response),
colour=discord.Colour.green()
))

View File

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

View File

@@ -0,0 +1,3 @@
from . import settings
from . import greetings
from . import roles

View File

@@ -0,0 +1,6 @@
from data import Table, RowTable
autoroles = Table('autoroles')
bot_autoroles = Table('bot_autoroles')
past_member_roles = Table('past_member_roles')

View File

@@ -0,0 +1,29 @@
import discord
from LionContext import LionContext as Context
from meta import client
from .settings import greeting_message, greeting_channel, returning_message
@client.add_after_event('member_join')
async def send_greetings(client, member):
guild = member.guild
returning = bool(client.data.lions.fetch((guild.id, member.id)))
# Handle greeting message
channel = greeting_channel.get(guild.id).value
if channel is not None:
if channel == greeting_channel.DMCHANNEL:
channel = member
ctx = Context(client, guild=guild, author=member)
if returning:
args = returning_message.get(guild.id).args(ctx)
else:
args = greeting_message.get(guild.id).args(ctx)
try:
await channel.send(**args)
except discord.HTTPException:
pass

View File

@@ -0,0 +1,115 @@
import asyncio
import discord
from collections import defaultdict
from meta import client
from core import Lion
from settings import GuildSettings
from .settings import autoroles, bot_autoroles, role_persistence
from .data import past_member_roles
# Locks to avoid storing the roles while adding them
# The locking is cautious, leaving data unchanged upon collision
locks = defaultdict(asyncio.Lock)
@client.add_after_event('member_join')
async def join_role_tracker(client, member):
"""
Add autoroles or saved roles as needed.
"""
guild = member.guild
if not guild.me.guild_permissions.manage_roles:
# We can't manage the roles here, don't try to give/restore the member roles
return
async with locks[(guild.id, member.id)]:
if role_persistence.get(guild.id).value and client.data.lions.fetch((guild.id, member.id)):
# Lookup stored roles
role_rows = past_member_roles.select_where(
guildid=guild.id,
userid=member.id
)
# Identify roles from roleids
roles = (guild.get_role(row['roleid']) for row in role_rows)
# Remove non-existent roles
roles = (role for role in roles if role is not None)
# Remove roles the client can't add
roles = [role for role in roles if role < guild.me.top_role]
if roles:
try:
await member.add_roles(
*roles,
reason="Restoring saved roles.",
)
except discord.HTTPException:
# This shouldn't ususally happen, but there are valid cases where it can
# E.g. the user left while we were restoring their roles
pass
# Event log!
GuildSettings(guild.id).event_log.log(
"Restored the following roles for returning member {}:\n{}".format(
member.mention,
', '.join(role.mention for role in roles)
),
title="Saved roles restored"
)
else:
# Add autoroles
roles = bot_autoroles.get(guild.id).value if member.bot else autoroles.get(guild.id).value
# Remove roles the client can't add
roles = [role for role in roles if role < guild.me.top_role]
if roles:
try:
await member.add_roles(
*roles,
reason="Adding autoroles.",
)
except discord.HTTPException:
# This shouldn't ususally happen, but there are valid cases where it can
# E.g. the user left while we were adding autoroles
pass
# Event log!
GuildSettings(guild.id).event_log.log(
"Gave {} the guild autoroles:\n{}".format(
member.mention,
', '.join(role.mention for role in roles)
),
titles="Autoroles added"
)
@client.add_after_event('member_remove')
async def left_role_tracker(client, member):
"""
Delete and re-store member roles when they leave the server.
"""
if (member.guild.id, member.id) in locks and locks[(member.guild.id, member.id)].locked():
# Currently processing a join event
# Which means the member left while we were adding their roles
# Cautiously return, not modifying the saved role data
return
# Delete existing member roles for this user
# NOTE: Not concurrency-safe
past_member_roles.delete_where(
guildid=member.guild.id,
userid=member.id,
)
if role_persistence.get(member.guild.id).value:
# Make sure the user has an associated lion, so we can detect when they rejoin
Lion.fetch(member.guild.id, member.id)
# Then insert the current member roles
values = [
(member.guild.id, member.id, role.id)
for role in member.roles
if not role.is_bot_managed() and not role.is_integration() and not role.is_default()
]
if values:
past_member_roles.insert_many(
*values,
insert_keys=('guildid', 'userid', 'roleid')
)

View File

@@ -0,0 +1,303 @@
import datetime
import discord
import settings
from settings import GuildSettings, GuildSetting
import settings.setting_types as stypes
from wards import guild_admin
from .data import autoroles, bot_autoroles
@GuildSettings.attach_setting
class greeting_channel(stypes.Channel, GuildSetting):
"""
Setting describing the destination of the greeting message.
Extended to support the following special values, with input and output supported.
Data `None` corresponds to `Off`.
Data `1` corresponds to `DM`.
"""
DMCHANNEL = object()
category = "New Members"
attr_name = 'greeting_channel'
_data_column = 'greeting_channel'
display_name = "welcome_channel"
desc = "Channel to send the welcome message in"
long_desc = (
"Channel to post the `welcome_message` in when a new user joins the server. "
"Accepts `DM` to indicate the welcome should be sent via direct message."
)
_accepts = (
"Text Channel name/id/mention, or `DM`, or `None` to disable."
)
_chan_type = discord.ChannelType.text
@classmethod
def _data_to_value(cls, id, data, **kwargs):
if data is None:
return None
elif data == 1:
return cls.DMCHANNEL
else:
return super()._data_to_value(id, data, **kwargs)
@classmethod
def _data_from_value(cls, id, value, **kwargs):
if value is None:
return None
elif value == cls.DMCHANNEL:
return 1
else:
return super()._data_from_value(id, value, **kwargs)
@classmethod
async def _parse_userstr(cls, ctx, id, userstr, **kwargs):
lower = userstr.lower()
if lower in ('0', 'none', 'off'):
return None
elif lower == 'dm':
return 1
else:
return await super()._parse_userstr(ctx, id, userstr, **kwargs)
@classmethod
def _format_data(cls, id, data, **kwargs):
if data is None:
return "Off"
elif data == 1:
return "DM"
else:
return "<#{}>".format(data)
@property
def success_response(self):
value = self.value
if not value:
return "Welcome messages are disabled."
elif value == self.DMCHANNEL:
return "Welcome messages will be sent via direct message."
else:
return "Welcome messages will be posted in {}".format(self.formatted)
@GuildSettings.attach_setting
class greeting_message(stypes.Message, GuildSetting):
category = "New Members"
attr_name = 'greeting_message'
_data_column = 'greeting_message'
display_name = 'welcome_message'
desc = "Welcome message sent to welcome new members."
long_desc = (
"Message to send to the configured `welcome_channel` when a member joins the server for the first time."
)
_default = r"""
{
"embed": {
"title": "Welcome!",
"thumbnail": {"url": "{guild_icon}"},
"description": "Hi {mention}!\nWelcome to **{guild_name}**! You are the **{member_count}**th member.\nThere are currently **{studying_count}** people studying.\nGood luck and stay productive!",
"color": 15695665
}
}
"""
_substitution_desc = {
'{mention}': "Mention the new member.",
'{user_name}': "Username of the new member.",
'{user_avatar}': "Avatar of the new member.",
'{guild_name}': "Name of this server.",
'{guild_icon}': "Server icon url.",
'{member_count}': "Number of members in the server.",
'{studying_count}': "Number of current voice channel members.",
}
def substitution_keys(self, ctx, **kwargs):
return {
'{mention}': ctx.author.mention,
'{user_name}': ctx.author.name,
'{user_avatar}': str(ctx.author.avatar_url),
'{guild_name}': ctx.guild.name,
'{guild_icon}': str(ctx.guild.icon_url),
'{member_count}': str(len(ctx.guild.members)),
'{studying_count}': str(len([member for ch in ctx.guild.voice_channels for member in ch.members]))
}
@property
def success_response(self):
return "The welcome message has been set!"
@GuildSettings.attach_setting
class returning_message(stypes.Message, GuildSetting):
category = "New Members"
attr_name = 'returning_message'
_data_column = 'returning_message'
display_name = 'returning_message'
desc = "Welcome message sent to returning members."
long_desc = (
"Message to send to the configured `welcome_channel` when a member returns to the server."
)
_default = r"""
{
"embed": {
"title": "Welcome Back {user_name}!",
"thumbnail": {"url": "{guild_icon}"},
"description": "Welcome back to **{guild_name}**!\nYou last studied with us <t:{last_time}:R>.\nThere are currently **{studying_count}** people studying.\nGood luck and stay productive!",
"color": 15695665
}
}
"""
_substitution_desc = {
'{mention}': "Mention the returning member.",
'{user_name}': "Username of the member.",
'{user_avatar}': "Avatar of the member.",
'{guild_name}': "Name of this server.",
'{guild_icon}': "Server icon url.",
'{member_count}': "Number of members in the server.",
'{studying_count}': "Number of current voice channel members.",
'{last_time}': "Unix timestamp of the last time the member studied.",
}
def substitution_keys(self, ctx, **kwargs):
return {
'{mention}': ctx.author.mention,
'{user_name}': ctx.author.name,
'{user_avatar}': str(ctx.author.avatar_url),
'{guild_name}': ctx.guild.name,
'{guild_icon}': str(ctx.guild.icon_url),
'{member_count}': str(len(ctx.guild.members)),
'{studying_count}': str(len([member for ch in ctx.guild.voice_channels for member in ch.members])),
'{last_time}': int(ctx.alion.data._timestamp.replace(tzinfo=datetime.timezone.utc).timestamp()),
}
@property
def success_response(self):
return "The returning message has been set!"
@GuildSettings.attach_setting
class starting_funds(stypes.Integer, GuildSetting):
category = "New Members"
attr_name = 'starting_funds'
_data_column = 'starting_funds'
display_name = 'starting_funds'
desc = "Coins given when a user first joins."
long_desc = (
"Members will be given this number of coins the first time they join the server."
)
_default = 1000
@property
def success_response(self):
return "Members will be given `{}` coins when they first join the server.".format(self.formatted)
@GuildSettings.attach_setting
class autoroles(stypes.RoleList, settings.ListData, settings.Setting):
category = "New Members"
write_ward = guild_admin
attr_name = 'autoroles'
_table_interface = autoroles
_id_column = 'guildid'
_data_column = 'roleid'
display_name = "autoroles"
desc = "Roles to give automatically to new members."
_force_unique = True
long_desc = (
"These roles will be given automatically to users when they join the server. "
"If `role_persistence` is enabled, the roles will only be given the first time a user joins the server."
)
# Flat cache, no need to expire
_cache = {}
@property
def success_response(self):
if self.value:
return "New members will be given the following roles:\n{}".format(self.formatted)
else:
return "New members will not automatically be given any roles."
@GuildSettings.attach_setting
class bot_autoroles(stypes.RoleList, settings.ListData, settings.Setting):
category = "New Members"
write_ward = guild_admin
attr_name = 'bot_autoroles'
_table_interface = bot_autoroles
_id_column = 'guildid'
_data_column = 'roleid'
display_name = "bot_autoroles"
desc = "Roles to give automatically to new bots."
_force_unique = True
long_desc = (
"These roles will be given automatically to bots when they join the server. "
"If `role_persistence` is enabled, the roles will only be given the first time a bot joins the server."
)
# Flat cache, no need to expire
_cache = {}
@property
def success_response(self):
if self.value:
return "New bots will be given the following roles:\n{}".format(self.formatted)
else:
return "New bots will not automatically be given any roles."
@GuildSettings.attach_setting
class role_persistence(stypes.Boolean, GuildSetting):
category = "New Members"
attr_name = "role_persistence"
_data_column = 'persist_roles'
display_name = "role_persistence"
desc = "Whether to remember member roles when they leave the server."
_outputs = {True: "Enabled", False: "Disabled"}
_default = True
long_desc = (
"When enabled, restores member roles when they rejoin the server.\n"
"This enables profile roles and purchased roles, such as field of study and colour roles, "
"as well as moderation roles, "
"such as the studyban and mute roles, to persist even when a member leaves and rejoins.\n"
"Note: Members who leave while this is disabled will not have their roles restored."
)
@property
def success_response(self):
if self.value:
return "Roles will now be restored when a member rejoins."
else:
return "Member roles will no longer be saved or restored."

View File

@@ -0,0 +1,6 @@
from .module import module
from . import data
from . import settings
from . import tracker
from . import command

View File

@@ -0,0 +1,943 @@
import asyncio
import discord
from discord import PartialEmoji
from cmdClient.lib import ResponseTimedOut, UserCancelled
from wards import guild_admin
from settings import UserInputError
from utils.lib import tick, cross
from .module import module
from .tracker import ReactionRoleMessage
from .data import reaction_role_reactions, reaction_role_messages
from . import settings
example_emoji = "🧮"
example_str = "🧮 mathematics, 🫀 biology, 💻 computer science, 🖼️ design, 🩺 medicine"
def _parse_messageref(ctx):
"""
Parse a message reference from the context message and return it.
Removes the parsed string from `ctx.args` if applicable.
Supports the following reference types, in precedence order:
- A Discord message reply reference.
- A message link.
- A message id.
Returns: (channelid, messageid)
`messageid` will be `None` if a valid reference was not found.
`channelid` will be `None` if the message was provided by pure id.
"""
target_id = None
target_chid = None
if ctx.msg.reference:
# True message reference extract message and return
target_id = ctx.msg.reference.message_id
target_chid = ctx.msg.reference.channel_id
elif ctx.args:
# Parse the first word of the message arguments
splits = ctx.args.split(maxsplit=1)
maybe_target = splits[0]
# Expect a message id or message link
if maybe_target.isdigit():
# Assume it is a message id
target_id = int(maybe_target)
elif '/' in maybe_target:
# Assume it is a link
# Split out the channelid and messageid, if possible
link_splits = maybe_target.rsplit('/', maxsplit=2)
if len(link_splits) > 1 and link_splits[-1].isdigit() and link_splits[-2].isdigit():
target_id = int(link_splits[-1])
target_chid = int(link_splits[-2])
# If we found a target id, truncate the arguments
if target_id is not None:
if len(splits) > 1:
ctx.args = splits[1].strip()
else:
ctx.args = ""
else:
# Last-ditch attempt, see if the argument could be a stored reaction
maybe_emoji = maybe_target.strip(',')
guild_message_rows = reaction_role_messages.fetch_rows_where(guildid=ctx.guild.id)
messages = [ReactionRoleMessage.fetch(row.messageid) for row in guild_message_rows]
emojis = {reaction.emoji: message for message in messages for reaction in message.reactions}
emoji_name_map = {emoji.name.lower(): emoji for emoji in emojis}
emoji_id_map = {emoji.id: emoji for emoji in emojis if emoji.id}
result = _parse_emoji(maybe_emoji, emoji_name_map, emoji_id_map)
if result and result in emojis:
message = emojis[result]
target_id = message.messageid
target_chid = message.data.channelid
# Return the message reference
return (target_chid, target_id)
def _parse_emoji(emoji_str, name_map, id_map):
"""
Extract a PartialEmoji from a user provided emoji string, given the accepted raw names and ids.
"""
emoji = None
if len(emoji_str) < 10 and all(ord(char) >= 256 for char in emoji_str):
# The string is pure unicode, we assume built in emoji
emoji = PartialEmoji(name=emoji_str)
elif emoji_str.lower() in name_map:
emoji = name_map[emoji_str.lower()]
elif emoji_str.isdigit() and int(emoji_str) in id_map:
emoji = id_map[int(emoji_str)]
else:
# Attempt to parse as custom emoji
# Accept custom emoji provided in the full form
emoji_split = emoji_str.strip('<>:').split(':')
if len(emoji_split) in (2, 3) and emoji_split[-1].isdigit():
emoji_id = int(emoji_split[-1])
emoji_name = emoji_split[-2]
emoji_animated = emoji_split[0] == 'a'
emoji = PartialEmoji(
name=emoji_name,
id=emoji_id,
animated=emoji_animated
)
return emoji
async def reaction_ask(ctx, question, timeout=120, timeout_msg=None, cancel_msg=None):
"""
Asks the author the provided question in an embed, and provides check/cross reactions for answering.
"""
embed = discord.Embed(
colour=discord.Colour.orange(),
description=question
)
out_msg = await ctx.reply(embed=embed)
# Wait for a tick/cross
asyncio.create_task(out_msg.add_reaction(tick))
asyncio.create_task(out_msg.add_reaction(cross))
def check(reaction, user):
result = True
result = result and reaction.message == out_msg
result = result and user == ctx.author
result = result and (reaction.emoji == tick or reaction.emoji == cross)
return result
try:
reaction, _ = await ctx.client.wait_for(
'reaction_add',
check=check,
timeout=120
)
except asyncio.TimeoutError:
try:
await out_msg.edit(
embed=discord.Embed(
colour=discord.Colour.red(),
description=timeout_msg or "Prompt timed out."
)
)
except discord.HTTPException:
pass
raise ResponseTimedOut from None
if reaction.emoji == cross:
try:
await out_msg.edit(
embed=discord.Embed(
colour=discord.Colour.red(),
description=cancel_msg or "Cancelled."
)
)
except discord.HTTPException:
pass
raise UserCancelled from None
try:
await out_msg.delete()
except discord.HTTPException:
pass
return True
_message_setting_flags = {
'removable': settings.removable,
'maximum': settings.maximum,
'required_role': settings.required_role,
'log': settings.log,
'refunds': settings.refunds,
'default_price': settings.default_price,
}
_reaction_setting_flags = {
'price': settings.price,
'duration': settings.duration
}
@module.cmd(
"reactionroles",
group="Guild Configuration",
desc="Create and configure reaction role messages.",
aliases=('rroles',),
flags=(
'delete', 'remove==',
'enable', 'disable',
'required_role==', 'removable=', 'maximum=', 'refunds=', 'log=', 'default_price=',
'price=', 'duration=='
)
)
@guild_admin()
async def cmd_reactionroles(ctx, flags):
"""
Usage``:
{prefix}rroles
{prefix}rroles [enable|disable|delete] msglink
{prefix}rroles msglink [emoji1 role1, emoji2 role2, ...]
{prefix}rroles msglink --remove emoji1, emoji2, ...
{prefix}rroles msglink --message_setting [value]
{prefix}rroles msglink emoji --reaction_setting [value]
Description:
Create and configure "reaction roles", i.e. roles obtainable by \
clicking reactions on a particular message.
`msglink` is the link or message id of the message with reactions.
`emoji` should be given as the emoji itself, or the name or id.
`role` may be given by name, mention, or id.
Getting started:
First choose the message you want to add reaction roles to, \
and copy the link or message id for that message. \
Then run the command `{prefix}rroles link`, replacing `link` with the copied link, \
and follow the prompts.
For faster setup, use `{prefix}rroles link emoji1 role1, emoji2 role2` instead.
Editing reaction roles:
Remove roles with `{prefix}rroles link --remove emoji1, emoji2, ...`
Add/edit roles with `{prefix}rroles link emoji1 role1, emoji2 role2, ...`
Examples``:
{prefix}rroles {ctx.msg.id} 🧮 mathematics, 🫀 biology, 🩺 medicine
{prefix}rroles disable {ctx.msg.id}
PAGEBREAK:
Page 2
Advanced configuration:
Type `{prefix}rroles link` again to view the advanced setting window, \
and use `{prefix}rroles link --setting value` to modify the settings. \
See below for descriptions of each message setting.
For example to disable event logging, run `{prefix}rroles link --log off`.
For per-reaction settings, instead use `{prefix}rroles link emoji --setting value`.
*(!) Replace `setting` with one of the settings below!*
Message Settings::
maximum: Maximum number of roles obtainable from this message.
log: Whether to log reaction role usage into the event log.
removable: Whether the reactions roles can be remove by unreacting.
refunds: Whether to refund the role price when removing the role.
default_price: The default price of each role on this message.
required_role: The role required to use these reactions roles.
Reaction Settings::
price: The price of this reaction role. (May be negative for a reward.)
tduration: How long this role will last after being selected or bought.
Configuration Examples``:
{prefix}rroles {ctx.msg.id} --maximum 5
{prefix}rroles {ctx.msg.id} --default_price 20
{prefix}rroles {ctx.msg.id} --required_role None
{prefix}rroles {ctx.msg.id} 🧮 --price 1024
{prefix}rroles {ctx.msg.id} 🧮 --duration 7 days
"""
if not ctx.args:
# No target message provided, list the current reaction messages
# Or give a brief guide if there are no current reaction messages
guild_message_rows = reaction_role_messages.fetch_rows_where(guildid=ctx.guild.id)
if guild_message_rows:
# List messages
# First get the list of reaction role messages in the guild
messages = [ReactionRoleMessage.fetch(row.messageid) for row in guild_message_rows]
# Sort them by channelid and messageid
messages.sort(key=lambda m: (m.data.channelid, m.messageid))
# Build the message description strings
message_strings = []
for message in messages:
header = (
"`{}` in <#{}> ([Click to jump]({})){}".format(
message.messageid,
message.data.channelid,
message.message_link,
" (disabled)" if not message.enabled else ""
)
)
role_strings = [
"{} <@&{}>".format(reaction.emoji, reaction.data.roleid)
for reaction in message.reactions
]
role_string = '\n'.join(role_strings) or "No reaction roles!"
message_strings.append("{}\n{}".format(header, role_string))
pages = []
page = []
page_len = 0
page_chars = 0
i = 0
while i < len(message_strings):
message_string = message_strings[i]
chars = len(message_string)
lines = len(message_string.splitlines())
if (page and lines + page_len > 20) or (chars + page_chars > 2000):
pages.append('\n\n'.join(page))
page = []
page_len = 0
page_chars = 0
else:
page.append(message_string)
page_len += lines
page_chars += chars
i += 1
if page:
pages.append('\n\n'.join(page))
page_count = len(pages)
title = "Reaction Roles in {}".format(ctx.guild.name)
embeds = [
discord.Embed(
colour=discord.Colour.orange(),
description=page,
title=title
)
for page in pages
]
if page_count > 1:
[embed.set_footer(text="Page {} of {}".format(i + 1, page_count)) for i, embed in enumerate(embeds)]
await ctx.pager(embeds)
else:
# Send a setup guide
embed = discord.Embed(
title="No Reaction Roles set up!",
description=(
"To setup reaction roles, first copy the link or message id of the message you want to "
"add the roles to. Then run `{prefix}rroles link`, replacing `link` with the link you copied, "
"and follow the prompts.\n"
"See `{prefix}help rroles` for more information.".format(prefix=ctx.best_prefix)
),
colour=discord.Colour.orange()
)
await ctx.reply(embed=embed)
return
# Extract first word, look for a subcommand
splits = ctx.args.split(maxsplit=1)
subcmd = splits[0].lower()
if subcmd in ('enable', 'disable', 'delete'):
# Truncate arguments and extract target
if len(splits) > 1:
ctx.args = splits[1]
target_chid, target_id = _parse_messageref(ctx)
else:
target_chid = None
target_id = None
ctx.args = ''
# Handle subcommand special cases
if subcmd == 'enable':
if ctx.args and not target_id:
await ctx.error_reply(
"Couldn't find the message to enable!\n"
"**Usage:** `{}rroles enable [message link or id]`.".format(ctx.best_prefix)
)
elif not target_id:
# Confirm enabling of all reaction messages
await reaction_ask(
ctx,
"Are you sure you want to enable all reaction role messages in this server?",
timeout_msg="Prompt timed out, no reaction roles enabled.",
cancel_msg="User cancelled, no reaction roles enabled."
)
reaction_role_messages.update_where(
{'enabled': True},
guildid=ctx.guild.id
)
await ctx.embed_reply(
"All reaction role messages have been enabled.",
colour=discord.Colour.green(),
)
else:
# Fetch the target
target = ReactionRoleMessage.fetch(target_id)
if target is None:
await ctx.error_reply(
"This message doesn't have any reaction roles!\n"
"Run the command again without `enable` to assign reaction roles."
)
else:
# We have a valid target
if target.enabled:
await ctx.error_reply(
"This message is already enabled!"
)
else:
target.enabled = True
await ctx.embed_reply(
"The message has been enabled!"
)
elif subcmd == 'disable':
if ctx.args and not target_id:
await ctx.error_reply(
"Couldn't find the message to disable!\n"
"**Usage:** `{}rroles disable [message link or id]`.".format(ctx.best_prefix)
)
elif not target_id:
# Confirm disabling of all reaction messages
await reaction_ask(
ctx,
"Are you sure you want to disable all reaction role messages in this server?",
timeout_msg="Prompt timed out, no reaction roles disabled.",
cancel_msg="User cancelled, no reaction roles disabled."
)
reaction_role_messages.update_where(
{'enabled': False},
guildid=ctx.guild.id
)
await ctx.embed_reply(
"All reaction role messages have been disabled.",
colour=discord.Colour.green(),
)
else:
# Fetch the target
target = ReactionRoleMessage.fetch(target_id)
if target is None:
await ctx.error_reply(
"This message doesn't have any reaction roles! Nothing to disable."
)
else:
# We have a valid target
if not target.enabled:
await ctx.error_reply(
"This message is already disabled!"
)
else:
target.enabled = False
await ctx.embed_reply(
"The message has been disabled!"
)
elif subcmd == 'delete':
if ctx.args and not target_id:
await ctx.error_reply(
"Couldn't find the message to remove!\n"
"**Usage:** `{}rroles remove [message link or id]`.".format(ctx.best_prefix)
)
elif not target_id:
# Confirm disabling of all reaction messages
await reaction_ask(
ctx,
"Are you sure you want to remove all reaction role messages in this server?",
timeout_msg="Prompt timed out, no messages removed.",
cancel_msg="User cancelled, no messages removed."
)
reaction_role_messages.delete_where(
guildid=ctx.guild.id
)
await ctx.embed_reply(
"All reaction role messages have been removed.",
colour=discord.Colour.green(),
)
else:
# Fetch the target
target = ReactionRoleMessage.fetch(target_id)
if target is None:
await ctx.error_reply(
"This message doesn't have any reaction roles! Nothing to remove."
)
else:
# We have a valid target
target.delete()
await ctx.embed_reply(
"The message has been removed and is no longer a reaction role message."
)
return
else:
# Just extract target
target_chid, target_id = _parse_messageref(ctx)
# Handle target parsing issue
if target_id is None:
return await ctx.error_reply(
"Couldn't parse `{}` as a message id or message link!\n"
"See `{}help rroles` for detailed usage information.".format(ctx.args.split()[0], ctx.best_prefix)
)
# Get the associated ReactionRoleMessage, if it exists
target = ReactionRoleMessage.fetch(target_id)
# Get the target message
if target:
message = await target.fetch_message()
if not message:
# TODO: Consider offering some sort of `move` option here.
await ctx.error_reply(
"This reaction role message no longer exists!\n"
"Use `{}rroles delete {}` to remove it from the list.".format(ctx.best_prefix, target.messageid)
)
else:
message = None
if target_chid:
channel = ctx.guild.get_channel(target_chid)
if not channel:
await ctx.error_reply(
"The provided channel no longer exists!"
)
elif not isinstance(channel, discord.TextChannel):
await ctx.error_reply(
"The provided channel is not a text channel!"
)
else:
message = await channel.fetch_message(target_id)
if not message:
await ctx.error_reply(
"Couldn't find the specified message in {}!".format(channel.mention)
)
else:
out_msg = await ctx.embed_reply("Searching for `{}`".format(target_id))
message = await ctx.find_message(target_id)
try:
await out_msg.delete()
except discord.HTTPException:
pass
if not message:
await ctx.error_reply(
"Couldn't find the message `{}`!".format(target_id)
)
if not message:
return
# Handle the `remove` flag specially
# In particular, all other flags are ignored
if flags['remove']:
if not target:
await ctx.error_reply(
"The specified message has no reaction roles! Nothing to remove."
)
else:
# Parse emojis and remove from target
target_emojis = {reaction.emoji: reaction for reaction in target.reactions}
emoji_name_map = {emoji.name.lower(): emoji for emoji in target_emojis}
emoji_id_map = {emoji.id: emoji for emoji in target_emojis}
items = [item.strip() for item in flags['remove'].split(',')]
to_remove = [] # List of reactions to remove
for emoji_str in items:
emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map)
if emoji is None:
return await ctx.error_reply(
"Couldn't parse `{}` as an emoji! No reactions were removed.".format(emoji_str)
)
if emoji not in target_emojis:
return await ctx.error_reply(
"{} is not a reaction role for this message!".format(emoji)
)
to_remove.append(target_emojis[emoji])
# Delete reactions from data
description = '\n'.join("{} <@&{}>".format(reaction.emoji, reaction.data.roleid) for reaction in to_remove)
reaction_role_reactions.delete_where(reactionid=[reaction.reactionid for reaction in to_remove])
target.refresh()
# Ack
embed = discord.Embed(
colour=discord.Colour.green(),
title="Reaction Roles deactivated",
description=description
)
await ctx.reply(embed=embed)
return
# Any remaining arguments should be emoji specifications with optional role
# Parse these now
given_emojis = {} # Map PartialEmoji -> Optional[Role]
existing_emojis = set() # Set of existing reaction emoji identifiers
if ctx.args:
# First build the list of custom emojis we can accept by name
# We do this by reverse precedence, so the highest priority emojis are added last
custom_emojis = []
custom_emojis.extend(ctx.guild.emojis) # Custom emojis in the guild
if target:
custom_emojis.extend([r.emoji for r in target.reactions]) # Configured reaction roles on the target
custom_emojis.extend([r.emoji for r in message.reactions if r.custom_emoji]) # Actual reactions on the message
# Filter out the built in emojis and those without a name
custom_emojis = (emoji for emoji in custom_emojis if emoji.name and emoji.id)
# Build the maps to lookup provided custom emojis
emoji_name_map = {emoji.name.lower(): emoji for emoji in custom_emojis}
emoji_id_map = {emoji.id: emoji for emoji in custom_emojis}
# Now parse the provided emojis
# Assume that all-unicode strings are built-in emojis
# We can't assume much else unless we have a list of such emojis
splits = (split.strip() for line in ctx.args.splitlines() for split in line.split(',') if split)
splits = (split.split(maxsplit=1) for split in splits if split)
arg_emoji_strings = {
split[0]: split[1] if len(split) > 1 else None
for split in splits
} # emoji_str -> Optional[role_str]
arg_emoji_map = {}
for emoji_str, role_str in arg_emoji_strings.items():
emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map)
if emoji is None:
return await ctx.error_reply(
"Couldn't parse `{}` as an emoji!".format(emoji_str)
)
else:
arg_emoji_map[emoji] = role_str
# Final pass extracts roles
# If any new emojis were provided, their roles should be specified, we enforce this during role parsing
# First collect the existing emoji strings
if target:
for reaction in target.reactions:
emoji_id = reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id
existing_emojis.add(emoji_id)
# Now parse and assign the roles, building the final map
for emoji, role_str in arg_emoji_map.items():
emoji_id = emoji.name if emoji.id is None else emoji.id
role = None
if role_str:
role = await ctx.find_role(role_str, create=True, interactive=True, allow_notfound=False)
elif emoji_id not in existing_emojis:
return await ctx.error_reply(
"New emoji {} was given without an associated role!".format(emoji)
)
given_emojis[emoji] = role
# Next manage target creation or emoji editing, if required
if target is None:
# Reaction message creation wizard
# Confirm that they want to create a new reaction role message.
await reaction_ask(
ctx,
question="Do you want to set up new reaction roles for [this message]({})?".format(
message.jump_url
),
timeout_msg="Prompt timed out, no reaction roles created.",
cancel_msg="Reaction Role creation cancelled."
)
# Continue with creation
# Obtain emojis if not already provided
if not given_emojis:
# Prompt for the initial emojis
embed = discord.Embed(
colour=discord.Colour.orange(),
title="What reaction roles would you like to add?",
description=(
"Please now type the reaction roles you would like to add "
"in the form `emoji role`, where `role` is given by partial name or id. For example:"
"```{}```".format(example_str)
)
)
out_msg = await ctx.reply(embed=embed)
# Wait for a response
def check(msg):
return msg.author == ctx.author and msg.channel == ctx.ch and msg.content
try:
reply = await ctx.client.wait_for('message', check=check, timeout=300)
except asyncio.TimeoutError:
try:
await out_msg.edit(
embed=discord.Embed(
colour=discord.Colour.red(),
description="Prompt timed out, no reaction roles created."
)
)
except discord.HTTPException:
pass
return
rolestrs = reply.content
try:
await reply.delete()
except discord.HTTPException:
pass
# Attempt to parse the emojis
# First build the list of custom emojis we can accept by name
custom_emojis = []
custom_emojis.extend(ctx.guild.emojis) # Custom emojis in the guild
custom_emojis.extend(
r.emoji for r in message.reactions if r.custom_emoji
) # Actual reactions on the message
# Filter out the built in emojis and those without a name
custom_emojis = (emoji for emoji in custom_emojis if emoji.name and emoji.id)
# Build the maps to lookup provided custom emojis
emoji_name_map = {emoji.name.lower(): emoji for emoji in custom_emojis}
emoji_id_map = {emoji.id: emoji for emoji in custom_emojis}
# Now parse the provided emojis
# Assume that all-unicode strings are built-in emojis
# We can't assume much else unless we have a list of such emojis
splits = (split.strip() for line in rolestrs.splitlines() for split in line.split(',') if split)
splits = (split.split(maxsplit=1) for split in splits if split)
arg_emoji_strings = {
split[0]: split[1] if len(split) > 1 else None
for split in splits
} # emoji_str -> Optional[role_str]
# Check all the emojis have roles associated
for emoji_str, role_str in arg_emoji_strings.items():
if role_str is None:
return await ctx.error_reply(
"No role provided for `{}`! Reaction role creation cancelled.".format(emoji_str)
)
# Parse the provided roles and emojis
for emoji_str, role_str in arg_emoji_strings.items():
emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map)
if emoji is None:
return await ctx.error_reply(
"Couldn't parse `{}` as an emoji!".format(emoji_str)
)
else:
given_emojis[emoji] = await ctx.find_role(
role_str,
create=True,
interactive=True,
allow_notfound=False
)
if len(given_emojis) > 20:
return await ctx.error_reply("A maximum of 20 reactions are possible per message! Cancelling creation.")
# Create the ReactionRoleMessage
target = ReactionRoleMessage.create(
message.id,
message.guild.id,
message.channel.id
)
# Insert the reaction data directly
reaction_role_reactions.insert_many(
*((message.id, role.id, emoji.name, emoji.id, emoji.animated) for emoji, role in given_emojis.items()),
insert_keys=('messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated')
)
# Refresh the message to pick up the new reactions
target.refresh()
# Add the reactions to the message, if possible
existing_reactions = set(
reaction.emoji if not reaction.custom_emoji else
(reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id)
for reaction in message.reactions
)
missing = [
reaction.emoji for reaction in target.reactions
if (reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id) not in existing_reactions
]
if not any(emoji.id not in set(cemoji.id for cemoji in ctx.guild.emojis) for emoji in missing if emoji.id):
# We can add the missing emojis
for emoji in missing:
try:
await message.add_reaction(emoji)
except discord.HTTPException:
break
else:
missing = []
# Ack the creation
ack_msg = "Created `{}` new reaction roles on [this message]({})!".format(
len(target.reactions),
target.message_link
)
if missing:
ack_msg += "\nPlease add the missing reactions to the message!"
await ctx.embed_reply(
ack_msg
)
elif given_emojis:
# Update the target reactions
# Create a map of the emojis that need to be added or updated
needs_update = {
emoji: role for emoji, role in given_emojis.items() if role
}
# Fetch the existing target emojis to split the roles into inserts and updates
target_emojis = {reaction.emoji: reaction for reaction in target.reactions}
# Handle the new roles
insert_targets = {
emoji: role for emoji, role in needs_update.items() if emoji not in target_emojis
}
if insert_targets:
if len(insert_targets) + len(target_emojis) > 20:
return await ctx.error_reply("Too many reactions! A maximum of 20 reactions are possible per message!")
reaction_role_reactions.insert_many(
*(
(message.id, role.id, emoji.name, emoji.id, emoji.animated)
for emoji, role in insert_targets.items()
),
insert_keys=('messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated')
)
# Handle the updated roles
update_targets = {
target_emojis[emoji]: role for emoji, role in needs_update.items() if emoji in target_emojis
}
if update_targets:
reaction_role_reactions.update_many(
*((role.id, reaction.reactionid) for reaction, role in update_targets.items()),
set_keys=('roleid',),
where_keys=('reactionid',),
)
# Finally, refresh to load the new reactions
target.refresh()
# Now that the target is created/updated, all the provided emojis should be reactions
given_reactions = []
if given_emojis:
# Make a map of the existing reactions
existing_reactions = {
reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id: reaction
for reaction in target.reactions
}
given_reactions = [
existing_reactions[emoji.name if emoji.id is None else emoji.id]
for emoji in given_emojis
]
# Handle message setting updates
update_lines = [] # Setting update lines to display
update_columns = {} # Message data columns to update
for flag in _message_setting_flags:
if flags[flag]:
setting_class = _message_setting_flags[flag]
try:
setting = await setting_class.parse(target.messageid, ctx, flags[flag])
except UserInputError as e:
return await ctx.error_reply(
"{} {}\nNo settings were modified.".format(cross, e.msg),
title="Couldn't save settings!"
)
else:
update_lines.append(
"{} {}".format(tick, setting.success_response)
)
update_columns[setting._data_column] = setting.data
if update_columns:
# First write the data
reaction_role_messages.update_where(
update_columns,
messageid=target.messageid
)
# Then ack the setting update
if len(update_lines) > 1:
embed = discord.Embed(
colour=discord.Colour.green(),
title="Reaction Role message settings updated!",
description='\n'.join(update_lines)
)
else:
embed = discord.Embed(
colour=discord.Colour.green(),
description=update_lines[0]
)
await ctx.reply(embed=embed)
# Handle reaction setting updates
update_lines = [] # Setting update lines to display
update_columns = {} # Message data columns to update, for all given reactions
reactions = given_reactions or target.reactions
for flag in _reaction_setting_flags:
for reaction in reactions:
if flags[flag]:
setting_class = _reaction_setting_flags[flag]
try:
setting = await setting_class.parse(reaction.reactionid, ctx, flags[flag])
except UserInputError as e:
return await ctx.error_reply(
"{} {}\nNo reaction roles were modified.".format(cross, e.msg),
title="Couldn't save reaction role settings!",
)
else:
update_lines.append(
setting.success_response.format(reaction=reaction)
)
update_columns[setting._data_column] = setting.data
if update_columns:
# First write the data
reaction_role_reactions.update_where(
update_columns,
reactionid=[reaction.reactionid for reaction in reactions]
)
# Then ack the setting update
if len(update_lines) > 1:
blocks = ['\n'.join(update_lines[i:i+20]) for i in range(0, len(update_lines), 20)]
embeds = [
discord.Embed(
colour=discord.Colour.green(),
title="Reaction Role settings updated!",
description=block
) for block in blocks
]
await ctx.pager(embeds)
else:
embed = discord.Embed(
colour=discord.Colour.green(),
description=update_lines[0]
)
await ctx.reply(embed=embed)
# Show the reaction role message summary
# Build the reaction fields
reaction_fields = [] # List of tuples (name, value)
for reaction in target.reactions:
reaction_fields.append(
(
"{} {}".format(reaction.emoji.name, reaction.emoji if reaction.emoji.id else ''),
"<@&{}>\n{}".format(reaction.data.roleid, reaction.settings.tabulated())
)
)
# Build the final setting pages
description = (
"{settings_table}\n"
"To update a message setting: `{prefix}rroles messageid --setting value`\n"
"To update an emoji setting: `{prefix}rroles messageid emoji --setting value`\n"
"See examples and more usage information with `{prefix}help rroles`.\n"
"**(!) Replace the `setting` with one of the settings on this page.**\n"
).format(
prefix=ctx.best_prefix,
settings_table=target.settings.tabulated()
)
field_blocks = [reaction_fields[i:i+6] for i in range(0, len(reaction_fields), 6)]
page_count = len(field_blocks)
embeds = []
for i, block in enumerate(field_blocks):
title = "Reaction role settings for message id `{}`".format(target.messageid)
embed = discord.Embed(
title=title,
description=description
).set_author(
name="Click to jump to message",
url=target.message_link
)
for name, value in block:
embed.add_field(name=name, value=value)
if page_count > 1:
embed.set_footer(text="Page {} of {}".format(i+1, page_count))
embeds.append(embed)
# Finally, send the reaction role information
await ctx.pager(embeds)

View File

@@ -0,0 +1,22 @@
from data import Table, RowTable
reaction_role_messages = RowTable(
'reaction_role_messages',
('messageid', 'guildid', 'channelid',
'enabled',
'required_role', 'allow_deselction',
'max_obtainable', 'allow_refunds',
'event_log'),
'messageid'
)
reaction_role_reactions = RowTable(
'reaction_role_reactions',
('reactionid', 'messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated', 'price', 'timeout'),
'reactionid'
)
reaction_role_expiring = Table('reaction_role_expiring')

View File

@@ -0,0 +1,172 @@
import logging
import traceback
import asyncio
import discord
from meta import client
from utils.lib import utc_now
from settings import GuildSettings
from .module import module
from .data import reaction_role_expiring
_expiring = {}
_wakeup_event = asyncio.Event()
# TODO: More efficient data structure for min optimisation, e.g. pre-sorted with bisection insert
# Public expiry interface
def schedule_expiry(guildid, userid, roleid, expiry, reactionid=None):
"""
Schedule expiry of the given role for the given member at the given time.
This will also cancel any existing expiry for this member, role pair.
"""
reaction_role_expiring.delete_where(
guildid=guildid,
userid=userid,
roleid=roleid,
)
reaction_role_expiring.insert(
guildid=guildid,
userid=userid,
roleid=roleid,
expiry=expiry,
reactionid=reactionid
)
key = (guildid, userid, roleid)
_expiring[key] = expiry.timestamp()
_wakeup_event.set()
def cancel_expiry(*key):
"""
Cancel expiry for the given member and role, if it exists.
"""
guildid, userid, roleid = key
reaction_role_expiring.delete_where(
guildid=guildid,
userid=userid,
roleid=roleid,
)
if _expiring.pop(key, None) is not None:
# Wakeup the expiry tracker for recalculation
_wakeup_event.set()
def _next():
"""
Calculate the next member, role pair to expire.
"""
if _expiring:
key, _ = min(_expiring.items(), key=lambda pair: pair[1])
return key
else:
return None
async def _expire(key):
"""
Execute reaction role expiry for the given member and role.
This removes the role and logs the removal if applicable.
If the user is no longer in the guild, it removes the role from the persistent roles instead.
"""
guildid, userid, roleid = key
guild = client.get_guild(guildid)
if guild:
role = guild.get_role(roleid)
if role:
member = guild.get_member(userid)
if member:
log = GuildSettings(guildid).event_log.log
if role in member.roles:
# Remove role from member, and log if applicable
try:
await member.remove_roles(
role,
atomic=True,
reason="Expiring temporary reaction role."
)
except discord.HTTPException:
log(
"Failed to remove expired reaction role {} from {}.".format(
role.mention,
member.mention
),
colour=discord.Colour.red(),
title="Could not remove expired Reaction Role!"
)
else:
log(
"Removing expired reaction role {} from {}.".format(
role.mention,
member.mention
),
title="Reaction Role expired!"
)
else:
# Remove role from stored persistent roles, if existent
client.data.past_member_roles.delete_where(
guildid=guildid,
userid=userid,
roleid=roleid
)
reaction_role_expiring.delete_where(
guildid=guildid,
userid=userid,
roleid=roleid
)
async def _expiry_tracker(client):
"""
Track and launch role expiry.
"""
while True:
try:
key = _next()
diff = _expiring[key] - utc_now().timestamp() if key else None
await asyncio.wait_for(_wakeup_event.wait(), timeout=diff)
except asyncio.TimeoutError:
# Timeout means next doesn't exist or is ready to expire
if key and key in _expiring and _expiring[key] <= utc_now().timestamp() + 1:
_expiring.pop(key)
asyncio.create_task(_expire(key))
except Exception:
# This should be impossible, but catch and log anyway
client.log(
"Exception occurred while tracking reaction role expiry. Exception traceback follows.\n{}".format(
traceback.format_exc()
),
context="REACTION_ROLE_EXPIRY",
level=logging.ERROR
)
else:
# Wakeup event means that we should recalculate next
_wakeup_event.clear()
@module.launch_task
async def launch_expiry_tracker(client):
"""
Launch the role expiry tracker.
"""
asyncio.create_task(_expiry_tracker(client))
client.log("Reaction role expiry tracker launched.", context="REACTION_ROLE_EXPIRY")
@module.init_task
def load_expiring_roles(client):
"""
Initialise the expiring reaction role map, and attach it to the client.
"""
rows = reaction_role_expiring.select_where()
_expiring.clear()
_expiring.update({(row['guildid'], row['userid'], row['roleid']): row['expiry'].timestamp() for row in rows})
client.objects['expiring_reaction_roles'] = _expiring
if _expiring:
client.log(
"Loaded {} expiring reaction roles.".format(len(_expiring)),
context="REACTION_ROLE_EXPIRY"
)

View File

@@ -0,0 +1,3 @@
from LionModule import LionModule
module = LionModule("Reaction_Roles")

View File

@@ -0,0 +1,257 @@
from utils.lib import DotDict
from wards import guild_admin
from settings import ObjectSettings, ColumnData, Setting
import settings.setting_types as setting_types
from .data import reaction_role_messages, reaction_role_reactions
class RoleMessageSettings(ObjectSettings):
settings = DotDict()
class RoleMessageSetting(ColumnData, Setting):
_table_interface = reaction_role_messages
_id_column = 'messageid'
_create_row = False
write_ward = guild_admin
@RoleMessageSettings.attach_setting
class required_role(setting_types.Role, RoleMessageSetting):
attr_name = 'required_role'
_data_column = 'required_role'
display_name = "required_role"
desc = "Role required to use the reaction roles."
long_desc = (
"Members will be required to have the specified role to use the reactions on this message."
)
@property
def success_response(self):
if self.value:
return "Members need {} to use these reaction roles.".format(self.formatted)
else:
return "All members can now use these reaction roles."
@classmethod
def _get_guildid(cls, id: int, **kwargs):
return reaction_role_messages.fetch(id).guildid
@RoleMessageSettings.attach_setting
class removable(setting_types.Boolean, RoleMessageSetting):
attr_name = 'removable'
_data_column = 'removable'
display_name = "removable"
desc = "Whether the role is removable by deselecting the reaction."
long_desc = (
"If enabled, the role will be removed when the reaction is deselected."
)
_default = True
@property
def success_response(self):
if self.value:
return "Members will be able to remove roles by unreacting."
else:
return "Members will not be able to remove the reaction roles."
@RoleMessageSettings.attach_setting
class maximum(setting_types.Integer, RoleMessageSetting):
attr_name = 'maximum'
_data_column = 'maximum'
display_name = "maximum"
desc = "The maximum number of roles a member can get from this message."
long_desc = (
"The maximum number of roles that a member can get from this message. "
"They will be notified by DM if they attempt to add more.\n"
"The `removable` setting should generally be enabled with this setting."
)
accepts = "An integer number of roles, or `None` to remove the maximum."
_min = 0
@classmethod
def _format_data(cls, id, data, **kwargs):
if data is None:
return "No maximum!"
else:
return "`{}`".format(data)
@property
def success_response(self):
if self.value:
return "Members can get a maximum of `{}` roles from this message.".format(self.value)
else:
return "Members can now get all the roles from this mesage."
@RoleMessageSettings.attach_setting
class refunds(setting_types.Boolean, RoleMessageSetting):
attr_name = 'refunds'
_data_column = 'refunds'
display_name = "refunds"
desc = "Whether a user will be refunded when they deselect a role."
long_desc = (
"Whether to give the user a refund when they deselect a role by reaction. "
"This has no effect if `removable` is not enabled, or if the role removed has no cost."
)
_default = True
@property
def success_response(self):
if self.value:
return "Members will get a refund when they remove a role."
else:
return "Members will not get a refund when they remove a role."
@RoleMessageSettings.attach_setting
class default_price(setting_types.Integer, RoleMessageSetting):
attr_name = 'default_price'
_data_column = 'default_price'
display_name = "default_price"
desc = "Default price of reaction roles on this message."
long_desc = (
"Reaction roles on this message will have this cost if they do not have an individual price set."
)
accepts = "An integer number of coins. Use `0` or `None` to make roles free by default."
_default = 0
@classmethod
def _format_data(cls, id, data, **kwargs):
if not data:
return "Free"
else:
return "`{}` coins".format(data)
@property
def success_response(self):
if self.value:
return "Reaction roles on this message will cost `{}` coins by default.".format(self.value)
else:
return "Reaction roles on this message will be free by default."
@RoleMessageSettings.attach_setting
class log(setting_types.Boolean, RoleMessageSetting):
attr_name = 'log'
_data_column = 'event_log'
display_name = "log"
desc = "Whether to log reaction role usage in the event log."
long_desc = (
"When enabled, roles added or removed with reactions will be logged in the configured event log."
)
_default = True
@property
def success_response(self):
if self.value:
return "Role updates will now be logged."
else:
return "Role updates will not be logged."
class ReactionSettings(ObjectSettings):
settings = DotDict()
class ReactionSetting(ColumnData, Setting):
_table_interface = reaction_role_reactions
_id_column = 'reactionid'
_create_row = False
write_ward = guild_admin
@ReactionSettings.attach_setting
class price(setting_types.Integer, ReactionSetting):
attr_name = 'price'
_data_column = 'price'
display_name = "price"
desc = "Price of this reaction role (may be negative)."
long_desc = (
"The number of coins that will be deducted from the user when this reaction is used.\n"
"The number may be negative, in order to give a reward when the member choses the reaction."
)
accepts = "An integer number of coins. Use `0` to make the role free, or `None` to use the message default."
_max = 2 ** 20
@property
def default(self):
"""
The default price is given by the ReactionMessage price setting.
"""
return default_price.get(self._table_interface.fetch(self.id).messageid).value
@classmethod
def _format_data(cls, id, data, **kwargs):
if not data:
return "Free"
else:
return "`{}` coins".format(data)
@property
def success_response(self):
if self.value is not None:
return "{{reaction.emoji}} {{reaction.role.mention}} now costs `{}` coins.".format(self.value)
else:
return "{reaction.emoji} {reaction.role.mention} is now free."
@ReactionSettings.attach_setting
class duration(setting_types.Duration, ReactionSetting):
attr_name = 'duration'
_data_column = 'timeout'
display_name = "duration"
desc = "How long this reaction role will last."
long_desc = (
"If set, the reaction role will be removed after the configured duration. "
"Note that this does not affect existing members with the role, or existing expiries."
)
_default_multiplier = 3600
_show_days = True
_min = 600
@classmethod
def _format_data(cls, id, data, **kwargs):
if data is None:
return "Permanent"
else:
return super()._format_data(id, data, **kwargs)
@property
def success_response(self):
if self.value is not None:
return "{{reaction.emoji}} {{reaction.role.mention}} will expire `{}` after selection.".format(
self.formatted
)
else:
return "{reaction.emoji} {reaction.role.mention} will not expire."

View File

@@ -0,0 +1,590 @@
import asyncio
from codecs import ignore_errors
import logging
import traceback
import datetime
from collections import defaultdict
from typing import List, Mapping, Optional
from cachetools import LFUCache
import discord
from discord import PartialEmoji
from meta import client
from core import Lion
from data import Row
from data.conditions import THIS_SHARD
from utils.lib import utc_now
from settings import GuildSettings
from ..module import module
from .data import reaction_role_messages, reaction_role_reactions
from .settings import RoleMessageSettings, ReactionSettings
from .expiry import schedule_expiry, cancel_expiry
class ReactionRoleReaction:
"""
Light data class representing a reaction role reaction.
"""
__slots__ = ('reactionid', '_emoji', '_message', '_role')
def __init__(self, reactionid, message=None, **kwargs):
self.reactionid = reactionid
self._message: ReactionRoleMessage = None
self._role = None
self._emoji = None
@classmethod
def create(cls, messageid, roleid, emoji: PartialEmoji, message=None, **kwargs) -> 'ReactionRoleReaction':
"""
Create a new ReactionRoleReaction with the provided attributes.
`emoji` sould be provided as a PartialEmoji.
`kwargs` are passed transparently to the `insert` method.
"""
row = reaction_role_reactions.create_row(
messageid=messageid,
roleid=roleid,
emoji_name=emoji.name,
emoji_id=emoji.id,
emoji_animated=emoji.animated,
**kwargs
)
return cls(row.reactionid, message=message)
@property
def emoji(self) -> PartialEmoji:
if self._emoji is None:
data = self.data
self._emoji = PartialEmoji(
name=data.emoji_name,
animated=data.emoji_animated,
id=data.emoji_id,
)
return self._emoji
@property
def data(self) -> Row:
return reaction_role_reactions.fetch(self.reactionid)
@property
def settings(self) -> ReactionSettings:
return ReactionSettings(self.reactionid)
@property
def reaction_message(self):
if self._message is None:
self._message = ReactionRoleMessage.fetch(self.data.messageid)
return self._message
@property
def role(self):
if self._role is None:
guild = self.reaction_message.guild
if guild:
self._role = guild.get_role(self.data.roleid)
return self._role
class ReactionRoleMessage:
"""
Light data class representing a reaction role message.
Primarily acts as an interface to the corresponding Settings.
"""
__slots__ = ('messageid', '_message')
# Full live messageid cache for this client. Should always be up to date.
_messages: Mapping[int, 'ReactionRoleMessage'] = {} # messageid -> associated Reaction message
# Reaction cache for the live messages. Least frequently used, will be fetched on demand.
_reactions: Mapping[int, List[ReactionRoleReaction]] = LFUCache(1000) # messageid -> List of Reactions
# User-keyed locks so we only handle one reaction per user at a time
_locks: Mapping[int, asyncio.Lock] = defaultdict(asyncio.Lock) # userid -> Lock
def __init__(self, messageid):
self.messageid = messageid
self._message = None
@classmethod
def fetch(cls, messageid) -> 'ReactionRoleMessage':
"""
Fetch the ReactionRoleMessage for the provided messageid.
Returns None if the messageid is not registered.
"""
# Since the cache is assumed to be always up to date, just pass to fetch-from-cache.
return cls._messages.get(messageid, None)
@classmethod
def create(cls, messageid, guildid, channelid, **kwargs) -> 'ReactionRoleMessage':
"""
Create a ReactionRoleMessage with the given `messageid`.
Other `kwargs` are passed transparently to `insert`.
"""
# Insert the data
reaction_role_messages.create_row(
messageid=messageid,
guildid=guildid,
channelid=channelid,
**kwargs
)
# Create the ReactionRoleMessage
rmsg = cls(messageid)
# Add to the global cache
cls._messages[messageid] = rmsg
# Return the constructed ReactionRoleMessage
return rmsg
def delete(self):
"""
Delete this ReactionRoleMessage.
"""
# Remove message from cache
self._messages.pop(self.messageid, None)
# Remove reactions from cache
reactionids = [reaction.reactionid for reaction in self.reactions]
[self._reactions.pop(reactionid, None) for reactionid in reactionids]
# Remove message from data
reaction_role_messages.delete_where(messageid=self.messageid)
@property
def data(self) -> Row:
"""
Data row associated with this Message.
Passes directly to the RowTable cache.
Should not generally be used directly, use the settings interface instead.
"""
return reaction_role_messages.fetch(self.messageid)
@property
def settings(self):
"""
RoleMessageSettings associated to this Message.
"""
return RoleMessageSettings(self.messageid)
def refresh(self):
"""
Refresh the reaction cache for this message.
Returns the generated `ReactionRoleReaction`s for convenience.
"""
# Fetch reactions and pre-populate reaction cache
rows = reaction_role_reactions.fetch_rows_where(messageid=self.messageid, _extra="ORDER BY reactionid ASC")
reactions = [ReactionRoleReaction(row.reactionid) for row in rows]
self._reactions[self.messageid] = reactions
return reactions
@property
def reactions(self) -> List[ReactionRoleReaction]:
"""
Returns the list of active reactions for this message, as `ReactionRoleReaction`s.
Lazily fetches the reactions from data if they have not been loaded.
"""
reactions = self._reactions.get(self.messageid, None)
if reactions is None:
reactions = self.refresh()
return reactions
@property
def enabled(self) -> bool:
"""
Whether this Message is enabled.
Passes directly to data for efficiency.
"""
return self.data.enabled
@enabled.setter
def enabled(self, value: bool):
self.data.enabled = value
# Discord properties
@property
def guild(self) -> discord.Guild:
return client.get_guild(self.data.guildid)
@property
def channel(self) -> discord.TextChannel:
return client.get_channel(self.data.channelid)
async def fetch_message(self) -> discord.Message:
if self._message:
return self._message
channel = self.channel
if channel:
try:
self._message = await channel.fetch_message(self.messageid)
return self._message
except discord.NotFound:
# The message no longer exists
# TODO: Cache and data cleanup? Or allow moving after death?
pass
@property
def message(self) -> Optional[discord.Message]:
return self._message
@property
def message_link(self) -> str:
"""
Jump link tho the reaction message.
"""
return 'https://discord.com/channels/{}/{}/{}'.format(
self.data.guildid,
self.data.channelid,
self.messageid
)
# Event handlers
async def process_raw_reaction_add(self, payload):
"""
Process a general reaction add payload.
"""
event_log = GuildSettings(self.guild.id).event_log
async with self._locks[payload.user_id]:
reaction = next((reaction for reaction in self.reactions if reaction.emoji == payload.emoji), None)
if reaction:
# User pressed a live reaction. Process!
member = payload.member
lion = Lion.fetch(member.guild.id, member.id)
role = reaction.role
if reaction.role and (role not in member.roles):
# Required role check, make sure the user has the required role, if set.
required_role = self.settings.required_role.value
if required_role and required_role not in member.roles:
# Silently remove their reaction
try:
message = await self.fetch_message()
await message.remove_reaction(
payload.emoji,
member
)
except discord.HTTPException:
pass
return
# Maximum check, check whether the user already has too many roles from this message.
maximum = self.settings.maximum.value
if maximum is not None:
# Fetch the number of applicable roles the user has
roleids = set(reaction.data.roleid for reaction in self.reactions)
member_roleids = set(role.id for role in member.roles)
if len(roleids.intersection(member_roleids)) >= maximum:
# Notify the user
embed = discord.Embed(
title="Maximum group roles reached!",
description=(
"Couldn't give you **{}**, "
"because you already have `{}` roles from this group!".format(
role.name,
maximum
)
)
)
# Silently try to notify the user
try:
await member.send(embed=embed)
except discord.HTTPException:
pass
# Silently remove the reaction
try:
message = await self.fetch_message()
await message.remove_reaction(
payload.emoji,
member
)
except discord.HTTPException:
pass
return
# Economy hook, check whether the user can pay for the role.
price = reaction.settings.price.value
if price and price > lion.coins:
# They can't pay!
# Build the can't pay embed
embed = discord.Embed(
title="Insufficient funds!",
description="Sorry, **{}** costs `{}` coins, but you only have `{}`.".format(
role.name,
price,
lion.coins
),
colour=discord.Colour.red()
).set_footer(
icon_url=self.guild.icon_url,
text=self.guild.name
).add_field(
name="Jump Back",
value="[Click here]({})".format(self.message_link)
)
# Try to send them the embed, ignore errors
try:
await member.send(
embed=embed
)
except discord.HTTPException:
pass
# Remove their reaction, ignore errors
try:
message = await self.fetch_message()
await message.remove_reaction(
payload.emoji,
member
)
except discord.HTTPException:
pass
return
# Add the role
try:
await member.add_roles(
role,
atomic=True,
reason="Adding reaction role."
)
except discord.Forbidden:
event_log.log(
"Insufficient permissions to give {} the [reaction role]({}) {}".format(
member.mention,
self.message_link,
role.mention,
),
title="Failed to add reaction role",
colour=discord.Colour.red()
)
except discord.HTTPException:
event_log.log(
"Something went wrong while adding the [reaction role]({}) "
"{} to {}.".format(
self.message_link,
role.mention,
member.mention
),
title="Failed to add reaction role",
colour=discord.Colour.red()
)
client.log(
"Unexpected HTTPException encountered while adding '{}' (rid:{}) to "
"user '{}' (uid:{}) in guild '{}' (gid:{}).\n{}".format(
role.name,
role.id,
member,
member.id,
member.guild.name,
member.guild.id,
traceback.format_exc()
),
context="REACTION_ROLE_ADD",
level=logging.WARNING
)
else:
# Charge the user and notify them, if the price is set
if price:
lion.addCoins(-price)
# Notify the user of their purchase
embed = discord.Embed(
title="Purchase successful!",
description="You have purchased **{}** for `{}` coins!".format(
role.name,
price
),
colour=discord.Colour.green()
).set_footer(
icon_url=self.guild.icon_url,
text=self.guild.name
).add_field(
name="Jump Back",
value="[Click Here]({})".format(self.message_link)
)
try:
await member.send(embed=embed)
except discord.HTTPException:
pass
# Schedule the expiry, if required
duration = reaction.settings.duration.value
if duration:
expiry = utc_now() + datetime.timedelta(seconds=duration)
schedule_expiry(self.guild.id, member.id, role.id, expiry, reaction.reactionid)
else:
expiry = None
# Log the role modification if required
if self.settings.log.value:
event_log.log(
"Added [reaction role]({}) {} "
"to {}{}.{}".format(
self.message_link,
role.mention,
member.mention,
" for `{}` coins".format(price) if price else '',
"\nThis role will expire at <t:{:.0f}>.".format(
expiry.timestamp()
) if expiry else ''
),
title="Reaction Role Added"
)
async def process_raw_reaction_remove(self, payload):
"""
Process a general reaction remove payload.
"""
if self.settings.removable.value:
event_log = GuildSettings(self.guild.id).event_log
async with self._locks[payload.user_id]:
reaction = next((reaction for reaction in self.reactions if reaction.emoji == payload.emoji), None)
if reaction:
# User removed a live reaction. Process!
member = self.guild.get_member(payload.user_id)
role = reaction.role
if member and not member.bot and role and (role in member.roles):
# Check whether they have the required role, if set
required_role = self.settings.required_role.value
if required_role and required_role not in member.roles:
# Ignore the reaction removal
return
try:
await member.remove_roles(
role,
atomic=True,
reason="Removing reaction role."
)
except discord.Forbidden:
event_log.log(
"Insufficient permissions to remove "
"the [reaction role]({}) {} from {}".format(
self.message_link,
role.mention,
member.mention,
),
title="Failed to remove reaction role",
colour=discord.Colour.red()
)
except discord.HTTPException:
event_log.log(
"Something went wrong while removing the [reaction role]({}) "
"{} from {}.".format(
self.message_link,
role.mention,
member.mention
),
title="Failed to remove reaction role",
colour=discord.Colour.red()
)
client.log(
"Unexpected HTTPException encountered while removing '{}' (rid:{}) from "
"user '{}' (uid:{}) in guild '{}' (gid:{}).\n{}".format(
role.name,
role.id,
member,
member.id,
member.guild.name,
member.guild.id,
traceback.format_exc()
),
context="REACTION_ROLE_RM",
level=logging.WARNING
)
else:
# Economy hook, handle refund if required
price = reaction.settings.price.value
refund = self.settings.refunds.value
if price and refund:
# Give the user the refund
lion = Lion.fetch(self.guild.id, member.id)
lion.addCoins(price)
# Notify the user
embed = discord.Embed(
title="Role sold",
description=(
"You sold the role **{}** for `{}` coins.".format(
role.name,
price
)
),
colour=discord.Colour.green()
).set_footer(
icon_url=self.guild.icon_url,
text=self.guild.name
).add_field(
name="Jump Back",
value="[Click Here]({})".format(self.message_link)
)
try:
await member.send(embed=embed)
except discord.HTTPException:
pass
# Log role removal if required
if self.settings.log.value:
event_log.log(
"Removed [reaction role]({}) {} "
"from {}.".format(
self.message_link,
role.mention,
member.mention
),
title="Reaction Role Removed"
)
# Cancel any existing expiry
cancel_expiry(self.guild.id, member.id, role.id)
# TODO: Make all the embeds a bit nicer, and maybe make a consistent interface for them
# TODO: Handle RawMessageDelete event
# TODO: Handle permission errors when fetching message in config
@client.add_after_event('raw_reaction_add')
async def reaction_role_add(client, payload):
reaction_message = ReactionRoleMessage.fetch(payload.message_id)
if payload.guild_id and payload.user_id != client.user.id and reaction_message and reaction_message.enabled:
try:
await reaction_message.process_raw_reaction_add(payload)
except Exception:
# Unknown exception, catch and log it.
client.log(
"Unhandled exception while handling reaction message payload: {}\n{}".format(
payload,
traceback.format_exc()
),
context="REACTION_ROLE_ADD",
level=logging.ERROR
)
@client.add_after_event('raw_reaction_remove')
async def reaction_role_remove(client, payload):
reaction_message = ReactionRoleMessage.fetch(payload.message_id)
if payload.guild_id and reaction_message and reaction_message.enabled:
try:
await reaction_message.process_raw_reaction_remove(payload)
except Exception:
# Unknown exception, catch and log it.
client.log(
"Unhandled exception while handling reaction message payload: {}\n{}".format(
payload,
traceback.format_exc()
),
context="REACTION_ROLE_RM",
level=logging.ERROR
)
@module.init_task
def load_reaction_roles(client):
"""
Load the ReactionRoleMessages.
"""
rows = reaction_role_messages.fetch_rows_where(guildid=THIS_SHARD)
ReactionRoleMessage._messages = {row.messageid: ReactionRoleMessage(row.messageid) for row in rows}

View File

@@ -0,0 +1,65 @@
from io import StringIO
import discord
from wards import guild_admin
from data import tables
from core import Lion
from .module import module
@module.cmd("studyreset",
desc="Perform a reset of the server's study statistics.",
group="Guild Admin")
@guild_admin()
async def cmd_statreset(ctx):
"""
Usage``:
{prefix}studyreset
Description:
Perform a complete reset of the server's study statistics.
That is, deletes the tracked time of all members and removes their study badges.
This may be used to set "seasons" of study.
Before the reset, I will send a csv file with the current member statistics.
**This is not reversible.**
"""
if not await ctx.ask("Are you sure you want to reset the study time and badges for all members? "
"**THIS IS NOT REVERSIBLE!**"):
return
# Build the data csv
rows = tables.lions.select_where(
select_columns=('userid', 'tracked_time', 'coins', 'workout_count', 'b.roleid AS badge_roleid'),
_extra=(
"LEFT JOIN study_badges b ON last_study_badgeid = b.badgeid "
"WHERE members.guildid={}"
).format(ctx.guild.id)
)
header = "userid, tracked_time, coins, workouts, rank_roleid\n"
csv_rows = [
','.join(str(data) for data in row)
for row in rows
]
with StringIO() as stats_file:
stats_file.write(header)
stats_file.write('\n'.join(csv_rows))
stats_file.seek(0)
out_file = discord.File(stats_file, filename="guild_{}_member_statistics.csv".format(ctx.guild.id))
await ctx.reply(file=out_file)
# Reset the statistics
tables.lions.update_where(
{'tracked_time': 0},
guildid=ctx.guild.id
)
Lion.sync()
await ctx.embed_reply(
"The member study times have been reset!\n"
"(It may take a while for the studybadges to update.)"
)

View File

@@ -0,0 +1,7 @@
# flake8: noqa
from .module import module
from . import help
from . import links
from . import nerd
from . import join_message

View File

@@ -0,0 +1,237 @@
import discord
from cmdClient.checks import is_owner
from utils.lib import prop_tabulate
from utils import interactive, ctx_addons # noqa
from wards import is_guild_admin
from .module import module
from .lib import guide_link
new_emoji = " 🆕"
new_commands = {'botconfig', 'sponsors'}
# Set the command groups to appear in the help
group_hints = {
'Pomodoro': "*Stay in sync with your friends using our timers!*",
'Productivity': "*Use these to help you stay focused and productive!*",
'Statistics': "*StudyLion leaderboards and study statistics.*",
'Economy': "*Buy, sell, and trade with your hard-earned coins!*",
'Personal Settings': "*Tell me about yourself!*",
'Guild Admin': "*Dangerous administration commands!*",
'Guild Configuration': "*Control how I behave in your server.*",
'Meta': "*Information about me!*",
'Support Us': "*Support the team and keep the project alive by using LionGems!*"
}
standard_group_order = (
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings', 'Meta'),
)
mod_group_order = (
('Moderation', 'Meta'),
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings')
)
admin_group_order = (
('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings')
)
bot_admin_group_order = (
('Bot Admin', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings')
)
# Help embed format
# TODO: Add config fields for this
title = "StudyLion Command List"
header = """
[StudyLion](https://bot.studylions.com/) is a fully featured study assistant \
that tracks your study time and offers productivity tools \
such as to-do lists, task reminders, private study rooms, group accountability sessions, and much much more.\n
Use `{{ctx.best_prefix}}help <command>` (e.g. `{{ctx.best_prefix}}help send`) to learn how to use each command, \
or [click here]({guide_link}) for a comprehensive tutorial.
""".format(guide_link=guide_link)
@module.cmd("help",
group="Meta",
desc="StudyLion command list.",
aliases=('man', 'ls', 'list'))
async def cmd_help(ctx):
"""
Usage``:
{prefix}help [cmdname]
Description:
When used with no arguments, displays a list of commands with brief descriptions.
Otherwise, shows documentation for the provided command.
Examples:
{prefix}help
{prefix}help top
{prefix}help timezone
"""
if ctx.arg_str:
# Attempt to fetch the command
command = ctx.client.cmd_names.get(ctx.arg_str.strip(), None)
if command is None:
return await ctx.error_reply(
("Command `{}` not found!\n"
"Write `{}help` to see a list of commands.").format(ctx.args, ctx.best_prefix)
)
smart_help = getattr(command, 'smart_help', None)
if smart_help is not None:
return await smart_help(ctx)
help_fields = command.long_help.copy()
help_map = {field_name: i for i, (field_name, _) in enumerate(help_fields)}
if not help_map:
return await ctx.reply("No documentation has been written for this command yet!")
field_pages = [[]]
page_fields = field_pages[0]
for name, pos in help_map.items():
if name.endswith("``"):
# Handle codeline help fields
page_fields.append((
name.strip("`"),
"`{}`".format('`\n`'.join(help_fields[pos][1].splitlines()))
))
elif name.endswith(":"):
# Handle property/value help fields
lines = help_fields[pos][1].splitlines()
names = []
values = []
for line in lines:
split = line.split(":", 1)
names.append(split[0] if len(split) > 1 else "")
values.append(split[-1])
page_fields.append((
name.strip(':'),
prop_tabulate(names, values)
))
elif name == "Related":
# Handle the related field
names = [cmd_name.strip() for cmd_name in help_fields[pos][1].split(',')]
names.sort(key=len)
values = [
(getattr(ctx.client.cmd_names.get(cmd_name, None), 'desc', '') or '').format(ctx=ctx)
for cmd_name in names
]
page_fields.append((
name,
prop_tabulate(names, values)
))
elif name == "PAGEBREAK":
page_fields = []
field_pages.append(page_fields)
else:
page_fields.append((name, help_fields[pos][1]))
# Build the aliases
aliases = getattr(command, 'aliases', [])
alias_str = "(Aliases `{}`.)".format("`, `".join(aliases)) if aliases else ""
# Build the embeds
pages = []
for i, page_fields in enumerate(field_pages):
embed = discord.Embed(
title="`{}` command documentation. {}".format(
command.name,
alias_str
),
colour=discord.Colour(0x9b59b6)
)
for fieldname, fieldvalue in page_fields:
embed.add_field(
name=fieldname,
value=fieldvalue.format(ctx=ctx, prefix=ctx.best_prefix),
inline=False
)
embed.set_footer(
text="{}\n[optional] and <required> denote optional and required arguments, respectively.".format(
"Page {} of {}".format(i + 1, len(field_pages)) if len(field_pages) > 1 else '',
)
)
pages.append(embed)
# Post the embed
await ctx.pager(pages)
else:
# Build the command groups
cmd_groups = {}
for command in ctx.client.cmds:
# Get the command group
group = getattr(command, 'group', "Misc")
cmd_group = cmd_groups.get(group, [])
if not cmd_group:
cmd_groups[group] = cmd_group
# Add the command name and description to the group
cmd_group.append(
(command.name, (getattr(command, 'desc', '') + (new_emoji if command.name in new_commands else '')))
)
# Add any required aliases
for alias, desc in getattr(command, 'help_aliases', {}).items():
cmd_group.append((alias, desc))
# Turn the command groups into strings
stringy_cmd_groups = {}
for group_name, cmd_group in cmd_groups.items():
cmd_group.sort(key=lambda tup: len(tup[0]))
if ctx.alias == 'ls':
stringy_cmd_groups[group_name] = ', '.join(
f"`{name}`" for name, _ in cmd_group
)
else:
stringy_cmd_groups[group_name] = prop_tabulate(*zip(*cmd_group))
# Now put everything into a bunch of embeds
if await is_owner.run(ctx):
group_order = bot_admin_group_order
elif ctx.guild:
if is_guild_admin(ctx.author):
group_order = admin_group_order
elif ctx.guild_settings.mod_role.value in ctx.author.roles:
group_order = mod_group_order
else:
group_order = standard_group_order
else:
group_order = admin_group_order
help_embeds = []
for page_groups in group_order:
embed = discord.Embed(
description=header.format(ctx=ctx),
colour=discord.Colour(0x9b59b6),
title=title
)
for group in page_groups:
group_hint = group_hints.get(group, '').format(ctx=ctx)
group_str = stringy_cmd_groups.get(group, None)
if group_str:
embed.add_field(
name=group,
value="{}\n{}".format(group_hint, group_str).format(ctx=ctx),
inline=False
)
help_embeds.append(embed)
# Add the page numbers
for i, embed in enumerate(help_embeds):
embed.set_footer(text="Page {}/{}".format(i+1, len(help_embeds)))
# Send the embeds
if help_embeds:
await ctx.pager(help_embeds)
else:
await ctx.reply(
embed=discord.Embed(description=header, colour=discord.Colour(0x9b59b6))
)

View File

@@ -0,0 +1,50 @@
import discord
from cmdClient import cmdClient
from meta import client, conf
from .lib import guide_link, animation_link
message = """
Thank you for inviting me to your community.
Get started by typing `{prefix}help` to see my commands, and `{prefix}config info` \
to read about my configuration options!
To learn how to configure me and use all of my features, \
make sure to [click here]({guide_link}) to read our full setup guide.
Remember, if you need any help configuring me, \
want to suggest a feature, report a bug and stay updated, \
make sure to join our main support and study server by [clicking here]({support_link}).
Best of luck with your studies!
""".format(
guide_link=guide_link,
support_link=conf.bot.get('support_link'),
prefix=client.prefix
)
@client.add_after_event('guild_join', priority=0)
async def post_join_message(client: cmdClient, guild: discord.Guild):
try:
await guild.me.edit(nick="Leo")
except discord.HTTPException:
pass
if (channel := guild.system_channel) and channel.permissions_for(guild.me).embed_links:
embed = discord.Embed(
description=message
)
embed.set_author(
name="Hello everyone! My name is Leo, the StudyLion!",
icon_url="https://cdn.discordapp.com/emojis/933610591459872868.webp"
)
embed.set_image(url=animation_link)
try:
await channel.send(embed=embed)
except discord.HTTPException:
# Something went wrong sending the hi message
# Not much we can do about this
pass

View File

@@ -0,0 +1,5 @@
guide_link = "https://discord.studylions.com/tutorial"
animation_link = (
"https://media.discordapp.net/attachments/879412267731542047/926837189814419486/ezgif.com-resize.gif"
)

View File

@@ -0,0 +1,57 @@
import discord
from meta import conf
from LionContext import LionContext as Context
from .module import module
from .lib import guide_link
@module.cmd(
"support",
group="Meta",
desc=f"Have a question? Join my [support server]({conf.bot.get('support_link')})"
)
async def cmd_support(ctx: Context):
"""
Usage``:
{prefix}support
Description:
Replies with an invite link to my support server.
"""
await ctx.reply(
f"Click here to join my support server: {conf.bot.get('support_link')}"
)
@module.cmd(
"invite",
group="Meta",
desc=f"[Invite me]({conf.bot.get('invite_link')}) to your server so I can help your members stay productive!"
)
async def cmd_invite(ctx: Context):
"""
Usage``:
{prefix}invite
Description:
Replies with my invite link so you can add me to your server.
"""
embed = discord.Embed(
colour=discord.Colour.orange(),
description=f"Click here to add me to your server: {conf.bot.get('invite_link')}"
)
embed.add_field(
name="Setup tips",
value=(
"Remember to check out `{prefix}help` for the full command list, "
"and `{prefix}config info` for the configuration options.\n"
"[Click here]({guide}) for our comprehensive setup tutorial, and if you still have questions you can "
"join our support server [here]({support}) to talk to our friendly support team!"
).format(
prefix=ctx.best_prefix,
support=conf.bot.get('support_link'),
guide=guide_link
)
)
await ctx.reply(embed=embed)

View File

@@ -0,0 +1,3 @@
from LionModule import LionModule
module = LionModule("Meta")

View File

@@ -0,0 +1,144 @@
import datetime
import asyncio
import discord
import psutil
import sys
import gc
from data import NOTNULL
from data.queries import select_where
from utils.lib import prop_tabulate, utc_now
from LionContext import LionContext as Context
from .module import module
process = psutil.Process()
process.cpu_percent()
@module.cmd(
"nerd",
group="Meta",
desc="Information and statistics about me!"
)
async def cmd_nerd(ctx: Context):
"""
Usage``:
{prefix}nerd
Description:
View nerdy information and statistics about me!
"""
# Create embed
embed = discord.Embed(
colour=discord.Colour.orange(),
title="Nerd Panel",
description=(
"Hi! I'm [StudyLion]({studylion}), a study management bot owned by "
"[Ari Horesh]({ari}) and developed by [Conatum#5317]({cona}), with [contributors]({github})."
).format(
studylion="http://studylions.com/",
ari="https://arihoresh.com/",
cona="https://github.com/Intery",
github="https://github.com/StudyLions/StudyLion"
)
)
# ----- Study stats -----
# Current studying statistics
current_students, current_channels, current_guilds= (
ctx.client.data.current_sessions.select_one_where(
select_columns=(
"COUNT(*) AS studying_count",
"COUNT(DISTINCT(channelid)) AS channel_count",
"COUNT(DISTINCT(guildid)) AS guild_count"
)
)
)
# Past studying statistics
past_sessions, past_students, past_duration, past_guilds = ctx.client.data.session_history.select_one_where(
select_columns=(
"COUNT(*) AS session_count",
"COUNT(DISTINCT(userid)) AS user_count",
"SUM(duration) / 3600 AS total_hours",
"COUNT(DISTINCT(guildid)) AS guild_count"
)
)
# Tasklist statistics
tasks = ctx.client.data.tasklist.select_one_where(
select_columns=(
'COUNT(*)'
)
)[0]
tasks_completed = ctx.client.data.tasklist.select_one_where(
completed_at=NOTNULL,
select_columns=(
'COUNT(*)'
)
)[0]
# Timers
timer_count, timer_guilds = ctx.client.data.timers.select_one_where(
select_columns=("COUNT(*)", "COUNT(DISTINCT(guildid))")
)
study_fields = {
"Currently": f"`{current_students}` people working in `{current_channels}` rooms of `{current_guilds}` guilds",
"Recorded": f"`{past_duration}` hours from `{past_students}` people across `{past_sessions}` sessions",
"Tasks": f"`{tasks_completed}` out of `{tasks}` tasks completed",
"Timers": f"`{timer_count}` timers running in `{timer_guilds}` communities"
}
study_table = prop_tabulate(*zip(*study_fields.items()))
# ----- Shard statistics -----
shard_number = ctx.client.shard_id
shard_count = ctx.client.shard_count
guilds = len(ctx.client.guilds)
member_count = sum(guild.member_count for guild in ctx.client.guilds)
commands = len(ctx.client.cmds)
aliases = len(ctx.client.cmd_names)
dpy_version = discord.__version__
py_version = sys.version.split()[0]
data_version, data_time, _ = select_where(
"VersionHistory",
_extra="ORDER BY time DESC LIMIT 1"
)[0]
data_timestamp = int(data_time.replace(tzinfo=datetime.timezone.utc).timestamp())
shard_fields = {
"Shard": f"`{shard_number}` of `{shard_count}`",
"Guilds": f"`{guilds}` servers with `{member_count}` members (on this shard)",
"Commands": f"`{commands}` commands with `{aliases}` keywords",
"Version": f"`v{data_version}`, last updated <t:{data_timestamp}:F>",
"Py version": f"`{py_version}` running discord.py `{dpy_version}`"
}
shard_table = prop_tabulate(*zip(*shard_fields.items()))
# ----- Execution statistics -----
running_commands = len(ctx.client.active_contexts)
tasks = len(asyncio.all_tasks())
objects = len(gc.get_objects())
cpu_percent = process.cpu_percent()
mem_percent = int(process.memory_percent())
uptime = int(utc_now().timestamp() - process.create_time())
execution_fields = {
"Running": f"`{running_commands}` commands",
"Waiting for": f"`{tasks}` tasks to complete",
"Objects": f"`{objects}` loaded in memory",
"Usage": f"`{cpu_percent}%` CPU, `{mem_percent}%` MEM",
"Uptime": f"`{uptime // (24 * 3600)}` days, `{uptime // 3600 % 24:02}:{uptime // 60 % 60:02}:{uptime % 60:02}`"
}
execution_table = prop_tabulate(*zip(*execution_fields.items()))
# ----- Combine and output -----
embed.add_field(name="Study Stats", value=study_table, inline=False)
embed.add_field(name=f"Shard Info", value=shard_table, inline=False)
embed.add_field(name=f"Process Stats", value=execution_table, inline=False)
await ctx.reply(embed=embed)

View File

@@ -0,0 +1,9 @@
from .module import module
from . import data
from . import admin
from . import tickets
from . import video
from . import commands

View File

@@ -0,0 +1,109 @@
import discord
from settings import GuildSettings, GuildSetting
from wards import guild_admin
import settings
from .data import studyban_durations
@GuildSettings.attach_setting
class mod_log(settings.Channel, GuildSetting):
category = "Moderation"
attr_name = 'mod_log'
_data_column = 'mod_log_channel'
display_name = "mod_log"
desc = "Moderation event logging channel."
long_desc = (
"Channel to post moderation tickets.\n"
"These are produced when a manual or automatic moderation action is performed on a member. "
"This channel acts as a more context rich moderation history source than the audit log."
)
_chan_type = discord.ChannelType.text
@property
def success_response(self):
if self.value:
return "Moderation tickets will be posted to {}.".format(self.formatted)
else:
return "The moderation log has been unset."
@GuildSettings.attach_setting
class studyban_role(settings.Role, GuildSetting):
category = "Moderation"
attr_name = 'studyban_role'
_data_column = 'studyban_role'
display_name = "studyban_role"
desc = "The role given to members to prevent them from using server study features."
long_desc = (
"This role is to be given to members to prevent them from using the server's study features.\n"
"Typically, this role should act as a 'partial mute', and prevent the user from joining study voice channels, "
"or participating in study text channels.\n"
"It will be given automatically after study related offences, "
"such as not enabling video in the video-only channels."
)
@property
def success_response(self):
if self.value:
return "The study ban role is now {}.".format(self.formatted)
@GuildSettings.attach_setting
class studyban_durations(settings.SettingList, settings.ListData, settings.Setting):
category = "Moderation"
attr_name = 'studyban_durations'
_table_interface = studyban_durations
_id_column = 'guildid'
_data_column = 'duration'
_order_column = "rowid"
_default = [
5 * 60,
60 * 60,
6 * 60 * 60,
24 * 60 * 60,
168 * 60 * 60,
720 * 60 * 60
]
_setting = settings.Duration
write_ward = guild_admin
display_name = "studyban_durations"
desc = "Sequence of durations for automatic study bans."
long_desc = (
"This sequence describes how long a member will be automatically study-banned for "
"after committing a study-related offence (such as not enabling their video in video only channels).\n"
"If the sequence is `1d, 7d, 30d`, for example, the member will be study-banned "
"for `1d` on their first offence, `7d` on their second offence, and `30d` on their third. "
"On their fourth offence, they will not be unbanned.\n"
"This does not count pardoned offences."
)
accepts = (
"Comma separated list of durations in days/hours/minutes/seconds, for example `12h, 1d, 7d, 30d`."
)
# Flat cache, no need to expire objects
_cache = {}
@property
def success_response(self):
if self.value:
return "The automatic study ban durations are now {}.".format(self.formatted)
else:
return "Automatic study bans will never be reverted."

View File

@@ -0,0 +1,448 @@
"""
Shared commands for the moderation module.
"""
import asyncio
from collections import defaultdict
import discord
from cmdClient.lib import ResponseTimedOut
from wards import guild_moderator
from .module import module
from .tickets import Ticket, TicketType, TicketState
type_accepts = {
'note': TicketType.NOTE,
'notes': TicketType.NOTE,
'studyban': TicketType.STUDY_BAN,
'studybans': TicketType.STUDY_BAN,
'warn': TicketType.WARNING,
'warns': TicketType.WARNING,
'warning': TicketType.WARNING,
'warnings': TicketType.WARNING,
}
type_formatted = {
TicketType.NOTE: 'NOTE',
TicketType.STUDY_BAN: 'STUDYBAN',
TicketType.WARNING: 'WARNING',
}
type_summary_formatted = {
TicketType.NOTE: 'note',
TicketType.STUDY_BAN: 'studyban',
TicketType.WARNING: 'warning',
}
state_formatted = {
TicketState.OPEN: 'ACTIVE',
TicketState.EXPIRING: 'TEMP',
TicketState.EXPIRED: 'EXPIRED',
TicketState.PARDONED: 'PARDONED'
}
state_summary_formatted = {
TicketState.OPEN: 'Active',
TicketState.EXPIRING: 'Temporary',
TicketState.EXPIRED: 'Expired',
TicketState.REVERTED: 'Manually Reverted',
TicketState.PARDONED: 'Pardoned'
}
@module.cmd(
"tickets",
group="Moderation",
desc="View and filter the server moderation tickets.",
flags=('active', 'type=')
)
@guild_moderator()
async def cmd_tickets(ctx, flags):
"""
Usage``:
{prefix}tickets [@user] [--type <type>] [--active]
Description:
Display and optionally filter the moderation event history in this guild.
Flags::
type: Filter by ticket type. See **Ticket Types** below.
active: Only show in-effect tickets (i.e. hide expired and pardoned ones).
Ticket Types::
note: Moderation notes.
warn: Moderation warnings, both manual and automatic.
studyban: Bans from using study features from abusing the study system.
blacklist: Complete blacklisting from using my commands.
Ticket States::
Active: Active tickets that will not automatically expire.
Temporary: Active tickets that will automatically expire after a set duration.
Expired: Tickets that have automatically expired.
Reverted: Tickets with actions that have been reverted.
Pardoned: Tickets that have been pardoned and no longer apply to the user.
Examples:
{prefix}tickets {ctx.guild.owner.mention} --type warn --active
"""
# Parse filter fields
# First the user
if ctx.args:
userstr = ctx.args.strip('<@!&> ')
if not userstr.isdigit():
return await ctx.error_reply(
"**Usage:** `{prefix}tickets [@user] [--type <type>] [--active]`.\n"
"Please provide the `user` as a mention or id!".format(prefix=ctx.best_prefix)
)
filter_userid = int(userstr)
else:
filter_userid = None
if flags['type']:
typestr = flags['type'].lower()
if typestr not in type_accepts:
return await ctx.error_reply(
"Please see `{prefix}help tickets` for the valid ticket types!".format(prefix=ctx.best_prefix)
)
filter_type = type_accepts[typestr]
else:
filter_type = None
filter_active = flags['active']
# Build the filter arguments
filters = {'guildid': ctx.guild.id}
if filter_userid:
filters['targetid'] = filter_userid
if filter_type:
filters['ticket_type'] = filter_type
if filter_active:
filters['ticket_state'] = [TicketState.OPEN, TicketState.EXPIRING]
# Fetch the tickets with these filters
tickets = Ticket.fetch_tickets(**filters)
if not tickets:
if filters:
return await ctx.embed_reply("There are no tickets with these criteria!")
else:
return await ctx.embed_reply("There are no moderation tickets in this server!")
tickets = sorted(tickets, key=lambda ticket: ticket.data.guild_ticketid, reverse=True)
ticket_map = {ticket.data.guild_ticketid: ticket for ticket in tickets}
# Build the format string based on the filters
components = []
# Ticket id with link to message in mod log
components.append("[#{ticket.data.guild_ticketid}]({ticket.link})")
# Ticket creation date
components.append("<t:{timestamp:.0f}:d>")
# Ticket type, with current state
if filter_type is None:
if not filter_active:
components.append("`{ticket_type}{ticket_state}`")
else:
components.append("`{ticket_type}`")
elif not filter_active:
components.append("`{ticket_real_state}`")
if not filter_userid:
# Ticket user
components.append("<@{ticket.data.targetid}>")
if filter_userid or (filter_active and filter_type):
# Truncated ticket content
components.append("{content}")
format_str = ' | '.join(components)
# Break tickets into blocks
blocks = [tickets[i:i+10] for i in range(0, len(tickets), 10)]
# Build pages of tickets
ticket_pages = []
for block in blocks:
ticket_page = []
type_len = max(len(type_formatted[ticket.type]) for ticket in block)
state_len = max(len(state_formatted[ticket.state]) for ticket in block)
for ticket in block:
# First truncate content if required
content = ticket.data.content
if len(content) > 40:
content = content[:37] + '...'
# Build ticket line
line = format_str.format(
ticket=ticket,
timestamp=ticket.data.created_at.timestamp(),
ticket_type=type_formatted[ticket.type],
type_len=type_len,
ticket_state=" [{}]".format(state_formatted[ticket.state]) if ticket.state != TicketState.OPEN else '',
ticket_real_state=state_formatted[ticket.state],
state_len=state_len,
content=content
)
if ticket.state == TicketState.PARDONED:
line = "~~{}~~".format(line)
# Add to current page
ticket_page.append(line)
# Combine lines and add page to pages
ticket_pages.append('\n'.join(ticket_page))
# Build active ticket type summary
freq = defaultdict(int)
for ticket in tickets:
if ticket.state != TicketState.PARDONED:
freq[ticket.type] += 1
summary_pairs = [
(num, type_summary_formatted[ttype] + ('s' if num > 1 else ''))
for ttype, num in freq.items()
]
summary_pairs.sort(key=lambda pair: pair[0])
# num_len = max(len(str(num)) for num in freq.values())
# type_summary = '\n'.join(
# "**`{:<{}}`** {}".format(pair[0], num_len, pair[1])
# for pair in summary_pairs
# )
# # Build status summary
# freq = defaultdict(int)
# for ticket in tickets:
# freq[ticket.state] += 1
# num_len = max(len(str(num)) for num in freq.values())
# status_summary = '\n'.join(
# "**`{:<{}}`** {}".format(freq[state], num_len, state_str)
# for state, state_str in state_summary_formatted.items()
# if state in freq
# )
summary_strings = [
"**`{}`** {}".format(*pair) for pair in summary_pairs
]
if len(summary_strings) > 2:
summary = ', '.join(summary_strings[:-1]) + ', and ' + summary_strings[-1]
elif len(summary_strings) == 2:
summary = ' and '.join(summary_strings)
else:
summary = ''.join(summary_strings)
if summary:
summary += '.'
# Build embed info
title = "{}{}{}".format(
"Active " if filter_active else '',
"{} tickets ".format(type_formatted[filter_type]) if filter_type else "Tickets ",
(" for {}".format(ctx.guild.get_member(filter_userid) or filter_userid)
if filter_userid else " in {}".format(ctx.guild.name))
)
footer = "Click a ticket id to jump to it, or type the number to show the full ticket."
page_count = len(blocks)
if page_count > 1:
footer += "\nPage {{page_num}}/{}".format(page_count)
# Create embeds
embeds = [
discord.Embed(
title=title,
description="{}\n{}".format(summary, page),
colour=discord.Colour.orange(),
).set_footer(text=footer.format(page_num=i+1))
for i, page in enumerate(ticket_pages)
]
# Run output with cancellation and listener
out_msg = await ctx.pager(embeds, add_cancel=True)
old_task = _displays.pop((ctx.ch.id, ctx.author.id), None)
if old_task:
old_task.cancel()
_displays[(ctx.ch.id, ctx.author.id)] = display_task = asyncio.create_task(_ticket_display(ctx, ticket_map))
ctx.tasks.append(display_task)
await ctx.cancellable(out_msg, add_reaction=False)
_displays = {} # (channelid, userid) -> Task
async def _ticket_display(ctx, ticket_map):
"""
Display tickets when the ticket number is entered.
"""
current_ticket_msg = None
try:
while True:
# Wait for a number
try:
result = await ctx.client.wait_for(
"message",
check=lambda msg: (msg.author == ctx.author
and msg.channel == ctx.ch
and msg.content.isdigit()
and int(msg.content) in ticket_map),
timeout=60
)
except asyncio.TimeoutError:
return
# Delete the response
try:
await result.delete()
except discord.HTTPException:
pass
# Display the ticket
embed = ticket_map[int(result.content)].msg_args['embed']
if current_ticket_msg:
try:
await current_ticket_msg.edit(embed=embed)
except discord.HTTPException:
current_ticket_msg = None
if not current_ticket_msg:
try:
current_ticket_msg = await ctx.reply(embed=embed)
except discord.HTTPException:
return
asyncio.create_task(ctx.offer_delete(current_ticket_msg))
except asyncio.CancelledError:
if current_ticket_msg:
try:
await current_ticket_msg.delete()
except discord.HTTPException:
pass
@module.cmd(
"pardon",
group="Moderation",
desc="Pardon a ticket, or clear a member's moderation history.",
flags=('type=',)
)
@guild_moderator()
async def cmd_pardon(ctx, flags):
"""
Usage``:
{prefix}pardon ticketid, ticketid, ticketid
{prefix}pardon @user [--type <type>]
Description:
Marks the given tickets as no longer applicable.
These tickets will not be considered when calculating automod actions such as automatic study bans.
This may be used to mark warns or other tickets as no longer in-effect.
If the ticket is active when it is pardoned, it will be reverted, and any expiry cancelled.
Use the `{prefix}tickets` command to view the relevant tickets.
Flags::
type: Filter by ticket type. See **Ticket Types** in `{prefix}help tickets`.
Examples:
{prefix}pardon 21
{prefix}pardon {ctx.guild.owner.mention} --type warn
"""
usage = "**Usage**: `{prefix}pardon ticketid` or `{prefix}pardon @user`.".format(prefix=ctx.best_prefix)
if not ctx.args:
return await ctx.error_reply(
usage
)
# Parse provided tickets or filters
targetid = None
ticketids = []
args = {'guildid': ctx.guild.id}
if ',' in ctx.args:
# Assume provided numbers are ticketids.
items = [item.strip() for item in ctx.args.split(',')]
if not all(item.isdigit() for item in items):
return await ctx.error_reply(usage)
ticketids = [int(item) for item in items]
args['guild_ticketid'] = ticketids
else:
# Guess whether the provided numbers were ticketids or not
idstr = ctx.args.strip('<@!&> ')
if not idstr.isdigit():
return await ctx.error_reply(usage)
maybe_id = int(idstr)
if maybe_id > 4194304: # Testing whether it is greater than the minimum snowflake id
# Assume userid
targetid = maybe_id
args['targetid'] = maybe_id
# Add the type filter if provided
if flags['type']:
typestr = flags['type'].lower()
if typestr not in type_accepts:
return await ctx.error_reply(
"Please see `{prefix}help tickets` for the valid ticket types!".format(prefix=ctx.best_prefix)
)
args['ticket_type'] = type_accepts[typestr]
else:
# Assume guild ticketid
ticketids = [maybe_id]
args['guild_ticketid'] = maybe_id
# Fetch the matching tickets
tickets = Ticket.fetch_tickets(**args)
# Check whether we have the right selection of tickets
if targetid and not tickets:
return await ctx.error_reply(
"<@{}> has no matching tickets to pardon!"
)
if ticketids and len(ticketids) != len(tickets):
# Not all of the ticketids were valid
difference = list(set(ticketids).difference(ticket.ticketid for ticket in tickets))
if len(difference) == 1:
return await ctx.error_reply(
"Couldn't find ticket `{}`!".format(difference[0])
)
else:
return await ctx.error_reply(
"Couldn't find any of the following tickets:\n`{}`".format(
'`, `'.join(difference)
)
)
# Check whether there are any tickets left to pardon
to_pardon = [ticket for ticket in tickets if ticket.state != TicketState.PARDONED]
if not to_pardon:
if ticketids and len(tickets) == 1:
ticket = tickets[0]
return await ctx.error_reply(
"[Ticket #{}]({}) is already pardoned!".format(ticket.data.guild_ticketid, ticket.link)
)
else:
return await ctx.error_reply(
"All of these tickets are already pardoned!"
)
# We now know what tickets we want to pardon
# Request the pardon reason
try:
reason = await ctx.input("Please provide a reason for the pardon.")
except ResponseTimedOut:
raise ResponseTimedOut("Prompt timed out, no tickets were pardoned.")
# Pardon the tickets
for ticket in to_pardon:
await ticket.pardon(ctx.author, reason)
# Finally, ack the pardon
if targetid:
await ctx.embed_reply(
"The active {}s for <@{}> have been cleared.".format(
type_summary_formatted[args['ticket_type']] if flags['type'] else 'ticket',
targetid
)
)
elif len(to_pardon) == 1:
ticket = to_pardon[0]
await ctx.embed_reply(
"[Ticket #{}]({}) was pardoned.".format(
ticket.data.guild_ticketid,
ticket.link
)
)
else:
await ctx.embed_reply(
"The following tickets were pardoned.\n{}".format(
", ".join(
"[#{}]({})".format(ticket.data.guild_ticketid, ticket.link)
for ticket in to_pardon
)
)
)

View File

@@ -0,0 +1,19 @@
from data import Table, RowTable
studyban_durations = Table('studyban_durations')
ticket_info = RowTable(
'ticket_info',
('ticketid', 'guild_ticketid',
'guildid', 'targetid', 'ticket_type', 'ticket_state', 'moderator_id', 'auto',
'log_msg_id', 'created_at',
'content', 'context', 'addendum', 'duration',
'file_name', 'file_data',
'expiry',
'pardoned_by', 'pardoned_at', 'pardoned_reason'),
'ticketid',
cache_size=20000
)
tickets = Table('tickets')

View File

@@ -0,0 +1,4 @@
from cmdClient import Module
module = Module("Moderation")

View File

@@ -0,0 +1,486 @@
import asyncio
import logging
import traceback
import datetime
import discord
from meta import client
from data.conditions import THIS_SHARD
from settings import GuildSettings
from utils.lib import FieldEnum, strfdelta, utc_now
from .. import data
from ..module import module
class TicketType(FieldEnum):
"""
The possible ticket types.
"""
NOTE = 'NOTE', 'Note'
WARNING = 'WARNING', 'Warning'
STUDY_BAN = 'STUDY_BAN', 'Study Ban'
MESAGE_CENSOR = 'MESSAGE_CENSOR', 'Message Censor'
INVITE_CENSOR = 'INVITE_CENSOR', 'Invite Censor'
class TicketState(FieldEnum):
"""
The possible ticket states.
"""
OPEN = 'OPEN', "Active"
EXPIRING = 'EXPIRING', "Active"
EXPIRED = 'EXPIRED', "Expired"
PARDONED = 'PARDONED', "Pardoned"
REVERTED = 'REVERTED', "Reverted"
class Ticket:
"""
Abstract base class representing a Ticketed moderation action.
"""
# Type of event the class represents
_ticket_type = None # type: TicketType
_ticket_types = {} # Map: TicketType -> Ticket subclass
_expiry_tasks = {} # Map: ticketid -> expiry Task
def __init__(self, ticketid, *args, **kwargs):
self.ticketid = ticketid
@classmethod
async def create(cls, *args, **kwargs):
"""
Method used to create a new ticket of the current type.
Should add a row to the ticket table, post the ticket, and return the Ticket.
"""
raise NotImplementedError
@property
def data(self):
"""
Ticket row.
This will usually be a row of `ticket_info`.
"""
return data.ticket_info.fetch(self.ticketid)
@property
def guild(self):
return client.get_guild(self.data.guildid)
@property
def target(self):
guild = self.guild
return guild.get_member(self.data.targetid) if guild else None
@property
def msg_args(self):
"""
Ticket message posted in the moderation log.
"""
args = {}
# Build embed
info = self.data
member = self.target
name = str(member) if member else str(info.targetid)
if info.auto:
title_fmt = "Ticket #{} | {} | {}[Auto] | {}"
else:
title_fmt = "Ticket #{} | {} | {} | {}"
title = title_fmt.format(
info.guild_ticketid,
TicketState(info.ticket_state).desc,
TicketType(info.ticket_type).desc,
name
)
embed = discord.Embed(
title=title,
description=info.content,
timestamp=info.created_at
)
embed.add_field(
name="Target",
value="<@{}>".format(info.targetid)
)
if not info.auto:
embed.add_field(
name="Moderator",
value="<@{}>".format(info.moderator_id)
)
# if info.duration:
# value = "`{}` {}".format(
# strfdelta(datetime.timedelta(seconds=info.duration)),
# "(Expiry <t:{:.0f}>)".format(info.expiry.timestamp()) if info.expiry else ""
# )
# embed.add_field(
# name="Duration",
# value=value
# )
if info.expiry:
if info.ticket_state == TicketState.EXPIRING:
embed.add_field(
name="Expires at",
value="<t:{:.0f}>\n(Duration: `{}`)".format(
info.expiry.timestamp(),
strfdelta(datetime.timedelta(seconds=info.duration))
)
)
elif info.ticket_state == TicketState.EXPIRED:
embed.add_field(
name="Expired",
value="<t:{:.0f}>".format(
info.expiry.timestamp(),
)
)
else:
embed.add_field(
name="Expiry",
value="<t:{:.0f}>".format(
info.expiry.timestamp()
)
)
if info.context:
embed.add_field(
name="Context",
value=info.context,
inline=False
)
if info.addendum:
embed.add_field(
name="Notes",
value=info.addendum,
inline=False
)
if self.state == TicketState.PARDONED:
embed.add_field(
name="Pardoned",
value=(
"Pardoned by <@{}> at <t:{:.0f}>.\n{}"
).format(
info.pardoned_by,
info.pardoned_at.timestamp(),
info.pardoned_reason or ""
),
inline=False
)
embed.set_footer(text="ID: {}".format(info.targetid))
args['embed'] = embed
# Add file
if info.file_name:
args['file'] = discord.File(info.file_data, info.file_name)
return args
@property
def link(self):
"""
The link to the ticket in the moderation log.
"""
info = self.data
modlog = GuildSettings(info.guildid).mod_log.data
return 'https://discord.com/channels/{}/{}/{}'.format(
info.guildid,
modlog,
info.log_msg_id
)
@property
def state(self):
return TicketState(self.data.ticket_state)
@property
def type(self):
return TicketType(self.data.ticket_type)
async def update(self, **kwargs):
"""
Update ticket fields.
"""
fields = (
'targetid', 'moderator_id', 'auto', 'log_msg_id',
'content', 'expiry', 'ticket_state',
'context', 'addendum', 'duration', 'file_name', 'file_data',
'pardoned_by', 'pardoned_at', 'pardoned_reason',
)
params = {field: kwargs[field] for field in fields if field in kwargs}
if params:
data.ticket_info.update_where(params, ticketid=self.ticketid)
await self.update_expiry()
await self.post()
async def post(self):
"""
Post or update the ticket in the moderation log.
Also updates the saved message id.
"""
info = self.data
modlog = GuildSettings(info.guildid).mod_log.value
if not modlog:
return
resend = True
try:
if info.log_msg_id:
# Try to fetch the message
message = await modlog.fetch_message(info.log_msg_id)
if message:
if message.author.id == client.user.id:
# TODO: Handle file edit
await message.edit(embed=self.msg_args['embed'])
resend = False
else:
try:
await message.delete()
except discord.HTTPException:
pass
if resend:
message = await modlog.send(**self.msg_args)
self.data.log_msg_id = message.id
except discord.HTTPException:
client.log(
"Cannot post ticket (tid: {}) due to discord exception or issue.".format(self.ticketid)
)
except Exception:
# This should never happen in normal operation
client.log(
"Error while posting ticket (tid:{})! "
"Exception traceback follows.\n{}".format(
self.ticketid,
traceback.format_exc()
),
context="TICKETS",
level=logging.ERROR
)
@classmethod
def load_expiring(cls):
"""
Load and schedule all expiring tickets.
"""
# TODO: Consider changing this to a flat timestamp system, to avoid storing lots of coroutines.
# TODO: Consider only scheduling the expiries in the next day, and updating this once per day.
# TODO: Only fetch tickets from guilds we are in.
# Cancel existing expiry tasks
for task in cls._expiry_tasks.values():
if not task.done():
task.cancel()
# Get all expiring tickets
expiring_rows = data.tickets.select_where(
ticket_state=TicketState.EXPIRING,
guildid=THIS_SHARD
)
# Create new expiry tasks
now = utc_now()
cls._expiry_tasks = {
row['ticketid']: asyncio.create_task(
cls._schedule_expiry_for(
row['ticketid'],
(row['expiry'] - now).total_seconds()
)
) for row in expiring_rows
}
# Log
client.log(
"Loaded {} expiring tickets.".format(len(cls._expiry_tasks)),
context="TICKET_LOADER",
)
@classmethod
async def _schedule_expiry_for(cls, ticketid, delay):
"""
Schedule expiry for a given ticketid
"""
try:
await asyncio.sleep(delay)
ticket = Ticket.fetch(ticketid)
if ticket:
await asyncio.shield(ticket._expire())
except asyncio.CancelledError:
return
def update_expiry(self):
# Cancel any existing expiry task
task = self._expiry_tasks.pop(self.ticketid, None)
if task and not task.done():
task.cancel()
# Schedule a new expiry task, if applicable
if self.data.ticket_state == TicketState.EXPIRING:
self._expiry_tasks[self.ticketid] = asyncio.create_task(
self._schedule_expiry_for(
self.ticketid,
(self.data.expiry - utc_now()).total_seconds()
)
)
async def cancel_expiry(self):
"""
Cancel ticket expiry.
In particular, may be used if another ticket overrides `self`.
Sets the ticket state to `OPEN`, so that it no longer expires.
"""
if self.state == TicketState.EXPIRING:
# Update the ticket state
self.data.ticket_state = TicketState.OPEN
# Remove from expiry tsks
self.update_expiry()
# Repost
await self.post()
async def _revert(self, reason=None):
"""
Method used to revert the ticket action, e.g. unban or remove mute role.
Generally called by `pardon` and `_expire`.
May be overriden by the Ticket type, if they implement any revert logic.
Is a no-op by default.
"""
return
async def _expire(self):
"""
Method to automatically expire a ticket.
May be overriden by the Ticket type for more complex expiry logic.
Must set `data.ticket_state` to `EXPIRED` if applicable.
"""
if self.state == TicketState.EXPIRING:
client.log(
"Automatically expiring ticket (tid:{}).".format(self.ticketid),
context="TICKETS"
)
try:
await self._revert(reason="Automatic Expiry")
except Exception:
# This should never happen in normal operation
client.log(
"Error while expiring ticket (tid:{})! "
"Exception traceback follows.\n{}".format(
self.ticketid,
traceback.format_exc()
),
context="TICKETS",
level=logging.ERROR
)
# Update state
self.data.ticket_state = TicketState.EXPIRED
# Update log message
await self.post()
# Post a note to the modlog
modlog = GuildSettings(self.data.guildid).mod_log.value
if modlog:
try:
await modlog.send(
embed=discord.Embed(
colour=discord.Colour.orange(),
description="[Ticket #{}]({}) expired!".format(self.data.guild_ticketid, self.link)
)
)
except discord.HTTPException:
pass
async def pardon(self, moderator, reason, timestamp=None):
"""
Pardon process for the ticket.
May be overidden by the Ticket type for more complex pardon logic.
Must set `data.ticket_state` to `PARDONED` if applicable.
"""
if self.state != TicketState.PARDONED:
if self.state in (TicketState.OPEN, TicketState.EXPIRING):
try:
await self._revert(reason="Pardoned by {}".format(moderator.id))
except Exception:
# This should never happen in normal operation
client.log(
"Error while pardoning ticket (tid:{})! "
"Exception traceback follows.\n{}".format(
self.ticketid,
traceback.format_exc()
),
context="TICKETS",
level=logging.ERROR
)
# Update state
with self.data.batch_update():
self.data.ticket_state = TicketState.PARDONED
self.data.pardoned_at = utc_now()
self.data.pardoned_by = moderator.id
self.data.pardoned_reason = reason
# Update (i.e. remove) expiry
self.update_expiry()
# Update log message
await self.post()
@classmethod
def fetch_tickets(cls, *ticketids, **kwargs):
"""
Fetch tickets matching the given criteria (passed transparently to `select_where`).
Positional arguments are treated as `ticketids`, which are not supported in keyword arguments.
"""
if ticketids:
kwargs['ticketid'] = ticketids
# Set the ticket type to the class type if not specified
if cls._ticket_type and 'ticket_type' not in kwargs:
kwargs['ticket_type'] = cls._ticket_type
# This is actually mainly for caching, since we don't pass the data to the initialiser
rows = data.ticket_info.fetch_rows_where(
**kwargs
)
return [
cls._ticket_types[TicketType(row.ticket_type)](row.ticketid)
for row in rows
]
@classmethod
def fetch(cls, ticketid):
"""
Return the Ticket with the given id, if found, or `None` otherwise.
"""
tickets = cls.fetch_tickets(ticketid)
return tickets[0] if tickets else None
@classmethod
def register_ticket_type(cls, ticket_cls):
"""
Decorator to register a new Ticket subclass as a ticket type.
"""
cls._ticket_types[ticket_cls._ticket_type] = ticket_cls
return ticket_cls
@module.launch_task
async def load_expiring_tickets(client):
Ticket.load_expiring()

View File

@@ -0,0 +1,4 @@
from .Ticket import Ticket, TicketType, TicketState
from .studybans import StudyBanTicket
from .notes import NoteTicket
from .warns import WarnTicket

View File

@@ -0,0 +1,112 @@
"""
Note ticket implementation.
Guild moderators can add a note about a user, visible in their moderation history.
Notes appear in the moderation log and the user's ticket history, like any other ticket.
This module implements the Note TicketType and the `note` moderation command.
"""
from cmdClient.lib import ResponseTimedOut
from wards import guild_moderator
from ..module import module
from ..data import tickets
from .Ticket import Ticket, TicketType, TicketState
@Ticket.register_ticket_type
class NoteTicket(Ticket):
_ticket_type = TicketType.NOTE
@classmethod
async def create(cls, guildid, targetid, moderatorid, content, **kwargs):
"""
Create a new Note on a target.
`kwargs` are passed transparently to the table insert method.
"""
ticket_row = tickets.insert(
guildid=guildid,
targetid=targetid,
ticket_type=cls._ticket_type,
ticket_state=TicketState.OPEN,
moderator_id=moderatorid,
auto=False,
content=content,
**kwargs
)
# Create the note ticket
ticket = cls(ticket_row['ticketid'])
# Post the ticket and return
await ticket.post()
return ticket
@module.cmd(
"note",
group="Moderation",
desc="Add a Note to a member's record."
)
@guild_moderator()
async def cmd_note(ctx):
"""
Usage``:
{prefix}note @target
{prefix}note @target <content>
Description:
Add a note to the target's moderation record.
The note will appear in the moderation log and in the `tickets` command.
The `target` must be specificed by mention or user id.
If the `content` is not given, it will be prompted for.
Example:
{prefix}note {ctx.author.mention} Seen reading the `note` documentation.
"""
if not ctx.args:
return await ctx.error_reply(
"**Usage:** `{}note @target <content>`.".format(ctx.best_prefix)
)
# Extract the target. We don't require them to be in the server
splits = ctx.args.split(maxsplit=1)
target_str = splits[0].strip('<@!&> ')
if not target_str.isdigit():
return await ctx.error_reply(
"**Usage:** `{}note @target <content>`.\n"
"`target` must be provided by mention or userid.".format(ctx.best_prefix)
)
targetid = int(target_str)
# Extract or prompt for the content
if len(splits) != 2:
try:
content = await ctx.input("What note would you like to add?", timeout=300)
except ResponseTimedOut:
raise ResponseTimedOut("Prompt timed out, no note was created.")
else:
content = splits[1].strip()
# Create the note ticket
ticket = await NoteTicket.create(
ctx.guild.id,
targetid,
ctx.author.id,
content
)
if ticket.data.log_msg_id:
await ctx.embed_reply(
"Note on <@{}> created as [Ticket #{}]({}).".format(
targetid,
ticket.data.guild_ticketid,
ticket.link
)
)
else:
await ctx.embed_reply(
"Note on <@{}> created as Ticket #{}.".format(targetid, ticket.data.guild_ticketid)
)

View File

@@ -0,0 +1,126 @@
import datetime
import discord
from meta import client
from utils.lib import utc_now
from settings import GuildSettings
from data import NOT
from .. import data
from .Ticket import Ticket, TicketType, TicketState
@Ticket.register_ticket_type
class StudyBanTicket(Ticket):
_ticket_type = TicketType.STUDY_BAN
@classmethod
async def create(cls, guildid, targetid, moderatorid, reason, expiry=None, **kwargs):
"""
Create a new study ban ticket.
"""
# First create the ticket itself
ticket_row = data.tickets.insert(
guildid=guildid,
targetid=targetid,
ticket_type=cls._ticket_type,
ticket_state=TicketState.EXPIRING if expiry else TicketState.OPEN,
moderator_id=moderatorid,
auto=(moderatorid == client.user.id),
content=reason,
expiry=expiry,
**kwargs
)
# Create the Ticket
ticket = cls(ticket_row['ticketid'])
# Schedule ticket expiry, if applicable
if expiry:
ticket.update_expiry()
# Cancel any existing studyban expiry for this member
tickets = cls.fetch_tickets(
guildid=guildid,
ticketid=NOT(ticket_row['ticketid']),
targetid=targetid,
ticket_state=TicketState.EXPIRING
)
for ticket in tickets:
await ticket.cancel_expiry()
# Post the ticket
await ticket.post()
# Return the ticket
return ticket
async def _revert(self, reason=None):
"""
Revert the studyban by removing the role.
"""
guild_settings = GuildSettings(self.data.guildid)
role = guild_settings.studyban_role.value
target = self.target
if target and role:
try:
await target.remove_roles(
role,
reason="Reverting StudyBan: {}".format(reason)
)
except discord.HTTPException:
# TODO: Error log?
...
@classmethod
async def autoban(cls, guild, target, reason, **kwargs):
"""
Convenience method to automatically studyban a member, for the configured duration.
If the role is set, this will create and return a `StudyBanTicket` regardless of whether the
studyban was successful.
If the role is not set, or the ticket cannot be created, this will return `None`.
"""
# Get the studyban role, fail if there isn't one set, or the role doesn't exist
guild_settings = GuildSettings(guild.id)
role = guild_settings.studyban_role.value
if not role:
return None
# Attempt to add the role, record failure
try:
await target.add_roles(role, reason="Applying StudyBan: {}".format(reason[:400]))
except discord.HTTPException:
role_failed = True
else:
role_failed = False
# Calculate the applicable automatic duration and expiry
# First count the existing non-pardoned studybans for this target
studyban_count = data.tickets.select_one_where(
guildid=guild.id,
targetid=target.id,
ticket_type=cls._ticket_type,
ticket_state=NOT(TicketState.PARDONED),
select_columns=('COUNT(*)',)
)[0]
studyban_count = int(studyban_count)
# Then read the guild setting to find the applicable duration
studyban_durations = guild_settings.studyban_durations.value
if studyban_count < len(studyban_durations):
duration = studyban_durations[studyban_count]
expiry = utc_now() + datetime.timedelta(seconds=duration)
else:
duration = None
expiry = None
# Create the ticket and return
if role_failed:
kwargs['addendum'] = '\n'.join((
kwargs.get('addendum', ''),
"Could not add the studyban role! Please add the role manually and check my permissions."
))
return await cls.create(
guild.id, target.id, client.user.id, reason, duration=duration, expiry=expiry, **kwargs
)

View File

@@ -0,0 +1,153 @@
"""
Warn ticket implementation.
Guild moderators can officially warn a user via command.
This DMs the users with the warning.
"""
import datetime
import discord
from cmdClient.lib import ResponseTimedOut
from wards import guild_moderator
from ..module import module
from ..data import tickets
from .Ticket import Ticket, TicketType, TicketState
@Ticket.register_ticket_type
class WarnTicket(Ticket):
_ticket_type = TicketType.WARNING
@classmethod
async def create(cls, guildid, targetid, moderatorid, content, **kwargs):
"""
Create a new Warning for the target.
`kwargs` are passed transparently to the table insert method.
"""
ticket_row = tickets.insert(
guildid=guildid,
targetid=targetid,
ticket_type=cls._ticket_type,
ticket_state=TicketState.OPEN,
moderator_id=moderatorid,
content=content,
**kwargs
)
# Create the note ticket
ticket = cls(ticket_row['ticketid'])
# Post the ticket and return
await ticket.post()
return ticket
async def _revert(*args, **kwargs):
# Warnings don't have a revert process
pass
@module.cmd(
"warn",
group="Moderation",
desc="Officially warn a user for a misbehaviour."
)
@guild_moderator()
async def cmd_warn(ctx):
"""
Usage``:
{prefix}warn @target
{prefix}warn @target <reason>
Description:
The `target` must be specificed by mention or user id.
If the `reason` is not given, it will be prompted for.
Example:
{prefix}warn {ctx.author.mention} Don't actually read the documentation!
"""
if not ctx.args:
return await ctx.error_reply(
"**Usage:** `{}warn @target <reason>`.".format(ctx.best_prefix)
)
# Extract the target. We do require them to be in the server
splits = ctx.args.split(maxsplit=1)
target_str = splits[0].strip('<@!&> ')
if not target_str.isdigit():
return await ctx.error_reply(
"**Usage:** `{}warn @target <reason>`.\n"
"`target` must be provided by mention or userid.".format(ctx.best_prefix)
)
targetid = int(target_str)
target = ctx.guild.get_member(targetid)
if not target:
return await ctx.error_reply("Cannot warn a user who is not in the server!")
# Extract or prompt for the content
if len(splits) != 2:
try:
content = await ctx.input("Please give a reason for this warning!", timeout=300)
except ResponseTimedOut:
raise ResponseTimedOut("Prompt timed out, the member was not warned.")
else:
content = splits[1].strip()
# Create the warn ticket
ticket = await WarnTicket.create(
ctx.guild.id,
targetid,
ctx.author.id,
content
)
# Attempt to message the member
embed = discord.Embed(
title="You have received a warning!",
description=(
content
),
colour=discord.Colour.red(),
timestamp=datetime.datetime.utcnow()
)
embed.add_field(
name="Info",
value=(
"*Warnings appear in your moderation history. "
"Failure to comply, or repeated warnings, "
"may result in muting, studybanning, or server banning.*"
)
)
embed.set_footer(
icon_url=ctx.guild.icon_url,
text=ctx.guild.name
)
dm_msg = None
try:
dm_msg = await target.send(embed=embed)
except discord.HTTPException:
pass
# Get previous warnings
count = tickets.select_one_where(
guildid=ctx.guild.id,
targetid=targetid,
ticket_type=TicketType.WARNING,
ticket_state=[TicketState.OPEN, TicketState.EXPIRING],
select_columns=('COUNT(*)',)
)[0]
if count == 1:
prev_str = "This is their first warning."
else:
prev_str = "They now have `{}` warnings.".format(count)
await ctx.embed_reply(
"[Ticket #{}]({}): {} has been warned. {}\n{}".format(
ticket.data.guild_ticketid,
ticket.link,
target.mention,
prev_str,
"*Could not DM the user their warning!*" if not dm_msg else ''
)
)

View File

@@ -0,0 +1,4 @@
from . import data
from . import admin
from . import watchdog

View File

@@ -0,0 +1,128 @@
from collections import defaultdict
from settings import GuildSettings, GuildSetting
from wards import guild_admin
import settings
from .data import video_channels
@GuildSettings.attach_setting
class video_channels(settings.ChannelList, settings.ListData, settings.Setting):
category = "Video Channels"
attr_name = 'video_channels'
_table_interface = video_channels
_id_column = 'guildid'
_data_column = 'channelid'
_setting = settings.VoiceChannel
write_ward = guild_admin
display_name = "video_channels"
desc = "Channels where members are required to enable their video."
_force_unique = True
long_desc = (
"Members must keep their video enabled in these channels.\n"
"If they do not keep their video enabled, they will be asked to enable it in their DMS after `15` seconds, "
"and then kicked from the channel with another warning after the `video_grace_period` duration has passed.\n"
"After the first offence, if the `video_studyban` is enabled and the `studyban_role` is set, "
"they will also be automatically studybanned."
)
# Flat cache, no need to expire objects
_cache = {}
@property
def success_response(self):
if self.value:
return "Members must enable their video in the following channels:\n{}".format(self.formatted)
else:
return "There are no video-required channels set up."
@classmethod
async def launch_task(cls, client):
"""
Launch initialisation step for the `video_channels` setting.
Pre-fill cache for the guilds with currently active voice channels.
"""
active_guildids = [
guild.id
for guild in client.guilds
if any(channel.members for channel in guild.voice_channels)
]
if active_guildids:
cache = {guildid: [] for guildid in active_guildids}
rows = cls._table_interface.select_where(
guildid=active_guildids
)
for row in rows:
cache[row['guildid']].append(row['channelid'])
cls._cache.update(cache)
@GuildSettings.attach_setting
class video_studyban(settings.Boolean, GuildSetting):
category = "Video Channels"
attr_name = 'video_studyban'
_data_column = 'video_studyban'
display_name = "video_studyban"
desc = "Whether to studyban members if they don't enable their video."
long_desc = (
"If enabled, members who do not enable their video in the configured `video_channels` will be "
"study-banned after a single warning.\n"
"When disabled, members will only be warned and removed from the channel."
)
_default = True
_outputs = {True: "Enabled", False: "Disabled"}
@property
def success_response(self):
if self.value:
return "Members will now be study-banned if they don't enable their video in the configured video channels."
else:
return "Members will not be study-banned if they don't enable their video in video channels."
@GuildSettings.attach_setting
class video_grace_period(settings.Duration, GuildSetting):
category = "Video Channels"
attr_name = 'video_grace_period'
_data_column = 'video_grace_period'
display_name = "video_grace_period"
desc = "How long to wait before kicking/studybanning members who don't enable their video."
long_desc = (
"The period after a member has been asked to enable their video in a video-only channel "
"before they will be kicked from the channel, and warned or studybanned (if enabled)."
)
_default = 90
_default_multiplier = 1
@classmethod
def _format_data(cls, id: int, data, **kwargs):
"""
Return the string version of the data.
"""
if data is None:
return None
else:
return "`{} seconds`".format(data)
@property
def success_response(self):
return (
"Members who do not enable their video will "
"be disconnected after {}.".format(self.formatted)
)

View File

@@ -0,0 +1,4 @@
from data import Table, RowTable
video_channels = Table('video_channels')

View File

@@ -0,0 +1,381 @@
"""
Implements a tracker to warn, kick, and studyban members in video channels without video enabled.
"""
import asyncio
import logging
import datetime
import discord
from meta import client
from core import Lion
from utils.lib import strfdelta
from settings import GuildSettings
from ..tickets import StudyBanTicket, WarnTicket
from ..module import module
_tasks = {} # (guildid, userid) -> Task
async def _send_alert(member, embed, alert_channel):
"""
Sends an embed to the member.
If we can't reach the member, send it via alert_channel, if it exists.
Returns the message, if it was sent, otherwise None.
"""
try:
return await member.send(embed=embed)
except discord.Forbidden:
if alert_channel:
try:
return await alert_channel.send(
content=(
"{} (Please enable your DMs with me to get alerts privately!)"
).format(member.mention),
embed=embed
)
except discord.HTTPException:
pass
async def _join_video_channel(member, channel):
# Sanity checks
if not member.voice and member.voice.channel:
# Not in a voice channel
return
if member.voice.self_video:
# Already have video on
return
# First wait for 15 seconds for them to turn their video on
try:
await asyncio.sleep(15)
except asyncio.CancelledError:
# They left the channel or turned their video on
return
# Fetch the relevant settings and build embeds
guild_settings = GuildSettings(member.guild.id)
grace_period = guild_settings.video_grace_period.value
studyban = guild_settings.video_studyban.value
studyban_role = guild_settings.studyban_role.value
alert_channel = guild_settings.alert_channel.value
lion = Lion.fetch(member.guild.id, member.id)
previously_warned = lion.data.video_warned
request_embed = discord.Embed(
title="Please enable your video!",
description=(
"**You have joined the video-only channel {}!**\n"
"Please **enable your video** or **leave the channel** in the next `{}` seconds, "
"otherwise you will be **disconnected** and "
"potentially **banned** from using this server's study facilities."
).format(
channel.mention,
grace_period
),
colour=discord.Colour.orange(),
timestamp=datetime.datetime.utcnow()
).set_footer(
text=member.guild.name,
icon_url=member.guild.icon_url
)
thanks_embed = discord.Embed(
title="Thanks for enabling your video! Best of luck with your study.",
colour=discord.Colour.green(),
timestamp=datetime.datetime.utcnow()
).set_footer(
text=member.guild.name,
icon_url=member.guild.icon_url
)
bye_embed = discord.Embed(
title="Thanks for leaving the channel promptly!",
colour=discord.Colour.green(),
timestamp=datetime.datetime.utcnow()
).set_footer(
text=member.guild.name,
icon_url=member.guild.icon_url
)
# Send the notification message and wait for the grace period
out_msg = None
alert_task = asyncio.create_task(_send_alert(
member,
request_embed,
alert_channel
))
try:
out_msg = await asyncio.shield(alert_task)
await asyncio.sleep(grace_period)
except asyncio.CancelledError:
# They left the channel or turned their video on
# Finish the message task if it wasn't complete
if not alert_task.done():
out_msg = await alert_task
# Update the notification message
# The out_msg may be None here, if we have no way of reaching the member
if out_msg is not None:
try:
if not member.voice or not (member.voice.channel == channel):
await out_msg.edit(embed=bye_embed)
elif member.voice.self_video:
await out_msg.edit(embed=thanks_embed)
except discord.HTTPException:
pass
return
# Disconnect, notify, warn, and potentially study ban
# Don't allow this to be cancelled any more
_tasks.pop((member.guild.id, member.id), None)
# First disconnect
client.log(
("Disconnecting member {} (uid: {}) in guild {} (gid: {}) from video channel {} (cid:{}) "
"for not enabling their video.").format(
member.name,
member.id,
member.guild.name,
member.guild.id,
channel.name,
channel.id
),
context="VIDEO_WATCHDOG"
)
try:
await member.edit(
voice_channel=None,
reason="Member in video-only channel did not enable video."
)
except discord.HTTPException:
# TODO: Add it to the moderation ticket
# Error log?
...
# Then warn or study ban, with appropriate notification
only_warn = not previously_warned or not studyban or not studyban_role
if only_warn:
# Give them an official warning
embed = discord.Embed(
title="You have received a warning!",
description=(
"You must enable your camera in camera-only rooms."
),
colour=discord.Colour.red(),
timestamp=datetime.datetime.utcnow()
)
embed.add_field(
name="Info",
value=(
"*Warnings appear in your moderation history. "
"Failure to comply, or repeated warnings, "
"may result in muting, studybanning, or server banning.*"
)
)
embed.set_footer(
icon_url=member.guild.icon_url,
text=member.guild.name
)
await _send_alert(member, embed, alert_channel)
await WarnTicket.create(
member.guild.id,
member.id,
client.user.id,
"Failed to enable their video in time in the video channel {}.".format(channel.mention),
auto=True
)
# TODO: Warning ticket and related embed.
lion.data.video_warned = True
else:
# Apply an automatic studyban
ticket = await StudyBanTicket.autoban(
member.guild,
member,
"Failed to enable their video in time in the video channel {}.".format(channel.mention)
)
if ticket:
tip = "TIP: When joining a video only study room, always be ready to enable your video immediately!"
embed = discord.Embed(
title="You have been studybanned!",
description=(
"You have been banned from studying in **{}**.\n"
"Study features, including access to the server **study channels**, "
"will ***not be available to you until this ban is lifted.***".format(
member.guild.name,
)
),
colour=discord.Colour.red(),
timestamp=datetime.datetime.utcnow()
)
embed.add_field(
name="Reason",
value="Failure to enable your video in time in a video-only channel.\n\n*{}*".format(tip)
)
if ticket.data.duration:
embed.add_field(
name="Duration",
value="`{}` (Expires <t:{:.0f}>)".format(
strfdelta(datetime.timedelta(seconds=ticket.data.duration)),
ticket.data.expiry.timestamp()
),
inline=False
)
embed.set_footer(
text=member.guild.name,
icon_url=member.guild.icon_url
)
await _send_alert(member, embed, alert_channel)
else:
# This should be impossible
# TODO: Cautionary error logging
pass
@client.add_after_event("voice_state_update")
async def video_watchdog(client, member, before, after):
if member.bot:
return
task_key = (member.guild.id, member.id)
if after.channel != before.channel:
# Channel change, cancel any running tasks for the member
task = _tasks.pop(task_key, None)
if task and not task.done():
task.cancel()
# Check whether they are joining a video channel, run join logic if so
if after.channel and not after.self_video:
video_channel_ids = GuildSettings(member.guild.id).video_channels.data
if after.channel.id in video_channel_ids:
client.log(
("Launching join task for member {} (uid: {}) "
"in guild {} (gid: {}) and video channel {} (cid:{}).").format(
member.name,
member.id,
member.guild.name,
member.guild.id,
after.channel.name,
after.channel.id
),
context="VIDEO_WATCHDOG",
level=logging.DEBUG
)
_tasks[task_key] = asyncio.create_task(_join_video_channel(member, after.channel))
else:
video_channel_ids = GuildSettings(member.guild.id).video_channels.data
if after.channel and after.channel.id in video_channel_ids:
channel = after.channel
if after.self_video:
# If they have their video on, cancel any running tasks
task = _tasks.pop(task_key, None)
if task and not task.done():
task.cancel()
else:
# They have their video off
# Don't do anything if there are running tasks, the tasks will handle it
task = _tasks.get(task_key, None)
if task and not task.done():
return
# Otherwise, give them 10 seconds
_tasks[task_key] = task = asyncio.create_task(asyncio.sleep(10))
try:
await task
except asyncio.CancelledError:
# Task was cancelled, they left the channel or turned their video on
return
# Then kick them out, alert them, and event log it
client.log(
("Disconnecting member {} (uid: {}) in guild {} (gid: {}) from video channel {} (cid:{}) "
"for disabling their video.").format(
member.name,
member.id,
member.guild.name,
member.guild.id,
channel.name,
channel.id
),
context="VIDEO_WATCHDOG"
)
try:
await member.edit(
voice_channel=None,
reason="Removing non-video member from video-only channel."
)
await _send_alert(
member,
discord.Embed(
title="You have been kicked from the video channel.",
description=(
"You were disconnected from the video-only channel {} for disabling your video.\n"
"Please keep your video on at all times, and leave the channel if you need "
"to make adjustments!"
).format(
channel.mention,
),
colour=discord.Colour.red(),
timestamp=datetime.datetime.utcnow()
).set_footer(
text=member.guild.name,
icon_url=member.guild.icon_url
),
GuildSettings(member.guild.id).alert_channel.value
)
except discord.Forbidden:
GuildSettings(member.guild.id).event_log.log(
"I attempted to disconnect {} from the video-only channel {} "
"because they disabled their video, but I didn't have the required permissions!\n".format(
member.mention,
channel.mention
)
)
else:
GuildSettings(member.guild.id).event_log.log(
"{} was disconnected from the video-only channel {} "
"because they disabled their video.".format(
member.mention,
channel.mention
)
)
@module.launch_task
async def load_video_channels(client):
"""
Process existing video channel members.
Pre-fills the video channel cache by running the setting launch task.
Treats members without video on as having just joined.
"""
# Run the video channel initialisation to populate the setting cache
await GuildSettings.settings.video_channels.launch_task(client)
# Launch join tasks for all members in video channels without video enabled
video_channels = (
channel
for guild in client.guilds
for channel in guild.voice_channels
if channel.members and channel.id in GuildSettings.settings.video_channels.get(guild.id).data
)
to_task = [
(member, channel)
for channel in video_channels
for member in channel.members
if not member.voice.self_video
]
for member, channel in to_task:
_tasks[(member.guild.id, member.id)] = asyncio.create_task(_join_video_channel(member, channel))
if to_task:
client.log(
"Launched {} join tasks for members who need to enable their video.".format(len(to_task)),
context="VIDEO_CHANNEL_LAUNCH"
)

View File

View File

@@ -0,0 +1,5 @@
from .module import module
from . import commands
from . import data
from . import reminder

View File

@@ -0,0 +1,264 @@
import re
import asyncio
import datetime
import discord
from meta import sharding
from utils.lib import parse_dur, parse_ranges, multiselect_regex
from .module import module
from .data import reminders
from .reminder import Reminder
reminder_regex = re.compile(
r"""
(^)?(?P<type> (?: \b in) | (?: every))
\s*(?P<duration> (?: day| hour| (?:\d+\s*(?:(?:d|h|m|s)[a-zA-Z]*)?(?:\s|and)*)+))
(?:(?(1) (?:, | ; | : | \. | to)? | $))
""",
re.IGNORECASE | re.VERBOSE | re.DOTALL
)
reminder_limit = 20
@module.cmd(
name="remindme",
desc="Ask me to remind you about important tasks.",
group="Productivity",
aliases=('reminders', 'reminder'),
flags=('remove', 'clear')
)
async def cmd_remindme(ctx, flags):
"""
Usage``:
{prefix}remindme in <duration> to <task>
{prefix}remindme every <duration> to <task>
{prefix}reminders
{prefix}reminders --clear
{prefix}reminders --remove
Description:
Ask {ctx.client.user.name} to remind you about important tasks.
Examples``:
{prefix}remindme in 2h 20m, Revise chapter 1
{prefix}remindme every hour, Drink water!
{prefix}remindme Anatomy class in 8h 20m
"""
# TODO: (FUTURE) every day at 9:00
if flags['remove']:
# Do removal stuff
rows = reminders.fetch_rows_where(
userid=ctx.author.id,
_extra="ORDER BY remind_at ASC"
)
if not rows:
return await ctx.reply("You have no reminders to remove!")
live = [Reminder(row.reminderid) for row in rows]
if not ctx.args:
lines = []
num_field = len(str(len(live) - 1))
for i, reminder in enumerate(live):
lines.append(
"`[{:{}}]` | {}".format(
i,
num_field,
reminder.formatted
)
)
description = '\n'.join(lines)
description += (
"\n\nPlease select the reminders to remove, or type `c` to cancel.\n"
"(For example, respond with `1, 2, 3` or `1-3`.)"
)
embed = discord.Embed(
description=description,
colour=discord.Colour.orange(),
timestamp=datetime.datetime.utcnow()
).set_author(
name="Reminders for {}".format(ctx.author.display_name),
icon_url=ctx.author.avatar_url
)
out_msg = await ctx.reply(embed=embed)
def check(msg):
valid = msg.channel == ctx.ch and msg.author == ctx.author
valid = valid and (re.search(multiselect_regex, msg.content) or msg.content.lower() == 'c')
return valid
try:
message = await ctx.client.wait_for('message', check=check, timeout=60)
except asyncio.TimeoutError:
await out_msg.delete()
await ctx.error_reply("Session timed out. No reminders were deleted.")
return
try:
await out_msg.delete()
await message.delete()
except discord.HTTPException:
pass
if message.content.lower() == 'c':
return
to_delete = [
live[index].reminderid
for index in parse_ranges(message.content) if index < len(live)
]
else:
to_delete = [
live[index].reminderid
for index in parse_ranges(ctx.args) if index < len(live)
]
if not to_delete:
return await ctx.error_reply("Nothing to delete!")
# Delete the selected reminders
Reminder.delete(*to_delete)
# Ack
await ctx.embed_reply(
"{tick} Reminder{plural} deleted.".format(
tick='',
plural='s' if len(to_delete) > 1 else ''
)
)
elif flags['clear']:
# Do clear stuff
rows = reminders.fetch_rows_where(
userid=ctx.author.id,
)
if not rows:
return await ctx.reply("You have no reminders to remove!")
Reminder.delete(*(row.reminderid for row in rows))
await ctx.embed_reply(
"{tick} Reminders cleared.".format(
tick='',
)
)
elif ctx.args:
# Add a new reminder
content = None
duration = None
repeating = None
# First parse it
match = re.search(reminder_regex, ctx.args)
if match:
repeating = match.group('type').lower() == 'every'
duration_str = match.group('duration').lower()
if duration_str.isdigit():
duration = int(duration_str)
elif duration_str == 'day':
duration = 24 * 60 * 60
elif duration_str == 'hour':
duration = 60 * 60
else:
duration = parse_dur(duration_str)
content = (ctx.args[:match.start()] + ctx.args[match.end():]).strip()
if content.startswith('to '):
content = content[3:].strip()
else:
# Legacy parsing, without requiring "in" at the front
splits = ctx.args.split(maxsplit=1)
if len(splits) == 2 and splits[0].isdigit():
repeating = False
duration = int(splits[0]) * 60
content = splits[1].strip()
# Sanity checking
if not duration or not content:
return await ctx.error_reply(
"Sorry, I didn't understand your reminder!\n"
"See `{prefix}help remindme` for usage and examples.".format(prefix=ctx.best_prefix)
)
# Don't allow rapid repeating reminders
if repeating and duration < 10 * 60:
return await ctx.error_reply(
"You can't have a repeating reminder shorter than `10` minutes!"
)
# Check the user doesn't have too many reminders already
count = reminders.select_one_where(
userid=ctx.author.id,
select_columns=("COUNT(*)",)
)[0]
if count > reminder_limit:
return await ctx.error_reply(
"Sorry, you have reached your maximum of `{}` reminders!".format(reminder_limit)
)
# Create reminder
reminder = Reminder.create(
userid=ctx.author.id,
content=content,
message_link=ctx.msg.jump_url,
interval=duration if repeating else None,
remind_at=datetime.datetime.utcnow() + datetime.timedelta(seconds=duration)
)
# Schedule reminder
if sharding.shard_number == 0:
reminder.schedule()
# Ack
embed = discord.Embed(
title="Reminder Created!",
colour=discord.Colour.orange(),
description="Got it! I will remind you <t:{}:R>.".format(reminder.timestamp),
timestamp=datetime.datetime.utcnow()
)
await ctx.reply(embed=embed)
elif ctx.alias.lower() == 'remindme':
# Show hints about adding reminders
...
else:
# Show formatted list of reminders
rows = reminders.fetch_rows_where(
userid=ctx.author.id,
_extra="ORDER BY remind_at ASC"
)
if not rows:
return await ctx.reply("You have no reminders!")
live = [Reminder(row.reminderid) for row in rows]
lines = []
num_field = len(str(len(live) - 1))
for i, reminder in enumerate(live):
lines.append(
"`[{:{}}]` | {}".format(
i,
num_field,
reminder.formatted
)
)
description = '\n'.join(lines)
embed = discord.Embed(
description=description,
colour=discord.Colour.orange(),
timestamp=datetime.datetime.utcnow()
).set_author(
name="{}'s reminders".format(ctx.author.display_name),
icon_url=ctx.author.avatar_url
).set_footer(
text=(
"Click a reminder twice to jump to the context!\n"
"For more usage and examples see {}help reminders"
).format(ctx.best_prefix)
)
await ctx.reply(embed=embed)

View File

@@ -0,0 +1,8 @@
from data.interfaces import RowTable
reminders = RowTable(
'reminders',
('reminderid', 'userid', 'remind_at', 'content', 'message_link', 'interval', 'created_at', 'title', 'footer'),
'reminderid'
)

View File

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

View File

@@ -0,0 +1,234 @@
import asyncio
import datetime
import logging
import discord
from meta import client, sharding
from utils.lib import strfdur
from .data import reminders
from .module import module
class Reminder:
__slots__ = ('reminderid', '_task')
_live_reminders = {} # map reminderid -> Reminder
def __init__(self, reminderid):
self.reminderid = reminderid
self._task = None
@classmethod
def create(cls, **kwargs):
row = reminders.create_row(**kwargs)
return cls(row.reminderid)
@classmethod
def fetch(cls, *reminderids):
"""
Fetch an live reminders associated to the given reminderids.
"""
return [
cls._live_reminders[reminderid]
for reminderid in reminderids
if reminderid in cls._live_reminders
]
@classmethod
def delete(cls, *reminderids):
"""
Cancel and delete the given reminders in an idempotent fashion.
"""
# Cancel the rmeinders
for reminderid in reminderids:
if reminderid in cls._live_reminders:
cls._live_reminders[reminderid].cancel()
# Remove from data
if reminderids:
return reminders.delete_where(reminderid=reminderids)
else:
return []
@property
def data(self):
return reminders.fetch(self.reminderid)
@property
def timestamp(self):
"""
True unix timestamp for (next) reminder time.
"""
return int(self.data.remind_at.replace(tzinfo=datetime.timezone.utc).timestamp())
@property
def user(self):
"""
The discord.User that owns this reminder, if we can find them.
"""
return client.get_user(self.data.userid)
@property
def formatted(self):
"""
Single-line string format for the reminder, intended for an embed.
"""
content = self.data.content
trunc_content = content[:50] + '...' * (len(content) > 50)
if self.data.interval:
interval = self.data.interval
if interval == 24 * 60 * 60:
interval_str = "day"
elif interval == 60 * 60:
interval_str = "hour"
elif interval % (24 * 60 * 60) == 0:
interval_str = "`{}` days".format(interval // (24 * 60 * 60))
elif interval % (60 * 60) == 0:
interval_str = "`{}` hours".format(interval // (60 * 60))
else:
interval_str = "`{}`".format(strfdur(interval))
repeat = "(Every {})".format(interval_str)
else:
repeat = ""
return "<t:{timestamp}:R>, [{content}]({jump_link}) {repeat}".format(
jump_link=self.data.message_link,
content=trunc_content,
timestamp=self.timestamp,
repeat=repeat
)
def cancel(self):
"""
Cancel the live reminder waiting task, if it exists.
Does not remove the reminder from data. Use `Reminder.delete` for this.
"""
if self._task and not self._task.done():
self._task.cancel()
self._live_reminders.pop(self.reminderid, None)
def schedule(self):
"""
Schedule this reminder to be executed.
"""
asyncio.create_task(self._schedule())
self._live_reminders[self.reminderid] = self
async def _schedule(self):
"""
Execute this reminder after a sleep.
Accepts cancellation by aborting the scheduled execute.
"""
# Calculate time left
remaining = (self.data.remind_at - datetime.datetime.utcnow()).total_seconds()
# Create the waiting task and wait for it, accepting cancellation
self._task = asyncio.create_task(asyncio.sleep(remaining))
try:
await self._task
except asyncio.CancelledError:
return
await self._execute()
async def _execute(self):
"""
Execute the reminder.
"""
if not self.data:
# Reminder deleted elsewhere
return
if self.data.userid in client.user_blacklist():
self.delete(self.reminderid)
return
userid = self.data.userid
# Build the message embed
embed = discord.Embed(
title="You asked me to remind you!" if self.data.title is None else self.data.title,
colour=discord.Colour.orange(),
description=self.data.content,
timestamp=datetime.datetime.utcnow()
)
if self.data.message_link:
embed.add_field(name="Context?", value="[Click here]({})".format(self.data.message_link))
if self.data.interval:
embed.add_field(
name="Next reminder",
value="<t:{}:R>".format(
self.timestamp + self.data.interval
)
)
if self.data.footer:
embed.set_footer(text=self.data.footer)
# Update the reminder data, and reschedule if required
if self.data.interval:
next_time = self.data.remind_at + datetime.timedelta(seconds=self.data.interval)
rows = reminders.update_where(
{'remind_at': next_time},
reminderid=self.reminderid
)
self.schedule()
else:
rows = self.delete(self.reminderid)
if not rows:
# Reminder deleted elsewhere
return
# Send the message, if possible
if not (user := client.get_user(userid)):
try:
user = await client.fetch_user(userid)
except discord.HTTPException:
pass
if user:
try:
await user.send(embed=embed)
except discord.HTTPException:
# Nothing we can really do here. Maybe tell the user about their reminder next time?
pass
async def reminder_poll(client):
"""
One client/shard must continually poll for new or deleted reminders.
"""
# TODO: Clean this up with database signals or IPC
while True:
await asyncio.sleep(60)
client.log(
"Running new reminder poll.",
context="REMINDERS",
level=logging.DEBUG
)
rids = {row.reminderid for row in reminders.fetch_rows_where()}
to_delete = (rid for rid in Reminder._live_reminders if rid not in rids)
Reminder.delete(*to_delete)
[Reminder(rid).schedule() for rid in rids if rid not in Reminder._live_reminders]
@module.launch_task
async def schedule_reminders(client):
if sharding.shard_number == 0:
rows = reminders.fetch_rows_where()
for row in rows:
Reminder(row.reminderid).schedule()
client.log(
"Scheduled {} reminders.".format(len(rows)),
context="LAUNCH_REMINDERS"
)
if sharding.sharded:
asyncio.create_task(reminder_poll(client))

View File

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

View File

@@ -0,0 +1,76 @@
import discord
from settings import GuildSettings, GuildSetting
import settings
@GuildSettings.attach_setting
class rent_category(settings.Channel, GuildSetting):
category = "Rented Rooms"
attr_name = "rent_category"
_data_column = "renting_category"
display_name = "rent_category"
desc = "Category in which members can rent their own study rooms."
_default = None
long_desc = (
"Members can use the `rent` command to "
"buy the use of a new private voice channel in this category for `24h`."
)
_accepts = "A category channel."
_chan_type = discord.ChannelType.category
@property
def success_response(self):
if self.value:
return "Members may now rent private voice channels under **{}**.".format(self.value.name)
else:
return "Members may no longer rent private voice channels."
@GuildSettings.attach_setting
class rent_member_limit(settings.Integer, GuildSetting):
category = "Rented Rooms"
attr_name = "rent_member_limit"
_data_column = "renting_cap"
display_name = "rent_member_limit"
desc = "Maximum number of people that can be added to a rented room."
_default = 24
long_desc = (
"Maximum number of people a member can add to a rented private voice channel."
)
_accepts = "An integer number of members."
@property
def success_response(self):
return "Members will now be able to add at most `{}` people to their channel.".format(self.value)
@GuildSettings.attach_setting
class rent_room_price(settings.Integer, GuildSetting):
category = "Rented Rooms"
attr_name = "rent_room_price"
_data_column = "renting_price"
display_name = "rent_price"
desc = "Price of a privated voice channel."
_default = 1000
long_desc = (
"How much it costs for a member to rent a private voice channel."
)
_accepts = "An integer number of coins."
@property
def success_response(self):
return "Private voice channels now cost `{}` coins.".format(self.value)

View File

@@ -0,0 +1,215 @@
import discord
from cmdClient.checks import in_guild
from .module import module
from .rooms import Room
@module.cmd(
name="rent",
desc="Rent a private study room with your friends!",
group="Productivity",
aliases=('add',)
)
@in_guild()
async def cmd_rent(ctx):
"""
Usage``:
{prefix}rent
{prefix}rent @user1 @user2 @user3 ...
{prefix}rent add @user1 @user2 @user3 ...
{prefix}rent remove @user1 @user2 @user3 ...
Description:
Rent a private voice channel for 24 hours,\
and invite up to `{ctx.guild_settings.rent_member_limit.value}` mentioned users.
Use `{prefix}rent add` and `{prefix}rent remove` to give/revoke access to your room.
*Renting a private channel costs `{ctx.guild_settings.rent_room_price.value} LC`.*
"""
# TODO: More gracefully handle unexpected channel deletion
# Check if the category is set up
if not ctx.guild_settings.rent_category.value:
return await ctx.error_reply(
"The private study channel category has not been set up! Please come back later."
)
# Fetch the members' room, if it exists
room = Room.fetch(ctx.guild.id, ctx.author.id)
# Handle pre-deletion of the room
if room and not room.channel:
ctx.guild_settings.event_log.log(
title="Private study room not found!",
description="{}'s study room was deleted before it expired!".format(ctx.author.mention)
)
room.delete()
room = None
if room:
# Show room status, or add/remove remebers
lower = ctx.args.lower()
if ctx.msg.mentions and lower and (lower.startswith('-') or lower.startswith('remove')):
# Remove the mentioned members
# Extract members to remove
current_memberids = set(room.memberids)
if ctx.author in ctx.msg.mentions:
return await ctx.error_reply(
"You can't remove yourself from your own room!"
)
to_remove = (
member for member in ctx.msg.mentions
if member.id in current_memberids and member.id != ctx.author.id
)
to_remove = list(set(to_remove)) # Remove duplicates
# Check if there are no members to remove
if not to_remove:
return await ctx.error_reply(
"None of these members have access to your study room!"
)
# Finally, remove the members from the room and ack
await room.remove_members(*to_remove)
await ctx.embed_reply(
"The following members have been removed from your room:\n{}".format(
', '.join(member.mention for member in to_remove)
)
)
elif lower == 'delete':
if await ctx.ask("Are you sure you want to delete your study room? No refunds given!"):
# TODO: Better deletion log
await room._execute()
await ctx.embed_reply("Private study room deleted.")
elif ctx.msg.mentions:
# Add the mentioned members
# Extract members to add
current_memberids = set(room.memberids)
to_add = (
member for member in ctx.msg.mentions
if member.id not in current_memberids and member.id != ctx.author.id
)
to_add = list(set(to_add)) # Remove duplicates
# Check if there are no members to add
if not to_add:
return await ctx.error_reply(
"All of these members already have access to your room!"
)
# Check that they didn't provide too many members
limit = ctx.guild_settings.rent_member_limit.value
if len(to_add) + len(current_memberids) > limit:
return await ctx.error_reply(
"Too many members! You can invite at most `{}` members to your room.".format(
limit
)
)
# Finally, add the members to the room and ack
await room.add_members(*to_add)
await ctx.embed_reply(
"The following members have been given access to your room:\n{}".format(
', '.join(member.mention for member in to_add)
)
)
else:
# Show room status with hints for adding and removing members
# Ack command
embed = discord.Embed(
colour=discord.Colour.orange()
).set_author(
name="{}'s private room".format(ctx.author.display_name),
icon_url=ctx.author.avatar_url
).add_field(
name="Channel",
value=room.channel.mention
).add_field(
name="Expires",
value="<t:{}:R>".format(room.timestamp)
).add_field(
name="Members",
value=', '.join('<@{}>'.format(memberid) for memberid in room.memberids) or "None",
inline=False
).set_footer(
text=(
"Use '{prefix}rent add @mention' and '{prefix}rent remove @mention'\n"
"to add and remove members.".format(prefix=ctx.best_prefix)
),
icon_url="https://projects.iamcal.com/emoji-data/img-apple-64/1f4a1.png"
)
await ctx.reply(embed=embed)
else:
if ctx.args:
# Rent a new room
to_add = (
member for member in ctx.msg.mentions if member != ctx.author
)
to_add = list(set(to_add))
# Check that they provided at least one member
if not to_add:
return await ctx.error_reply(
"Please mention at least one user to add to your new room."
)
# Check that they didn't provide too many members
limit = ctx.guild_settings.rent_member_limit.value
if len(ctx.msg.mentions) > limit:
return await ctx.error_reply(
"Too many members! You can invite at most `{}` members to your room.".format(
limit
)
)
# Check that they have enough money for this
cost = ctx.guild_settings.rent_room_price.value
if ctx.alion.coins < cost:
return await ctx.error_reply(
"Sorry, a private room costs `{}` coins, but you only have `{}`.".format(
cost,
ctx.alion.coins
)
)
# Create the room
room = await Room.create(ctx.author, to_add)
# Deduct cost
ctx.alion.addCoins(-cost)
# Ack command
embed = discord.Embed(
colour=discord.Colour.orange(),
title="Private study room rented!",
).add_field(
name="Channel",
value=room.channel.mention
).add_field(
name="Expires",
value="<t:{}:R>".format(room.timestamp)
).add_field(
name="Members",
value=', '.join(member.mention for member in to_add),
inline=False
).set_footer(
text="See your room status at any time with {prefix}rent".format(prefix=ctx.best_prefix),
icon_url="https://projects.iamcal.com/emoji-data/img-apple-64/1f4a1.png"
)
await ctx.reply(embed=embed)
else:
# Suggest they get a room
await ctx.embed_reply(
"Rent a private study room for 24 hours with up to `{}` "
"friends by mentioning them with this command! (Rooms cost `{}` LionCoins.)\n"
"`{}rent @user1 @user2 ...`".format(
ctx.guild_settings.rent_member_limit.value,
ctx.guild_settings.rent_room_price.value,
ctx.best_prefix,
)
)

View File

@@ -0,0 +1,11 @@
from data import RowTable, Table
rented = RowTable(
'rented',
('channelid', 'guildid', 'ownerid', 'expires_at', 'created_at'),
'channelid'
)
rented_members = Table('rented_members')

View File

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

View File

@@ -0,0 +1,321 @@
import discord
import asyncio
import datetime
from cmdClient.lib import SafeCancellation
from meta import client
from data.conditions import THIS_SHARD
from settings import GuildSettings
from .data import rented, rented_members
from .module import module
class Room:
__slots__ = ('key', 'map_key', '_task')
everyone_overwrite = discord.PermissionOverwrite(
view_channel=False
)
owner_overwrite = discord.PermissionOverwrite(
view_channel=True,
connect=True,
priority_speaker=True
)
member_overwrite = discord.PermissionOverwrite(
view_channel=True,
connect=True,
)
_table = rented
_rooms = {} # map (guildid, userid) -> Room
def __init__(self, channelid):
self.key = channelid
self.map_key = (self.data.guildid, self.data.ownerid)
self._task = None
@classmethod
async def create(cls, owner: discord.Member, initial_members):
ownerid = owner.id
guild = owner.guild
guildid = guild.id
guild_settings = GuildSettings(guildid)
category = guild_settings.rent_category.value
if not category:
# This should never happen
return SafeCancellation("Rent category not set up!")
# First create the channel, with the needed overrides
overwrites = {
guild.default_role: cls.everyone_overwrite,
owner: cls.owner_overwrite
}
overwrites.update(
{member: cls.member_overwrite for member in initial_members}
)
try:
channel = await guild.create_voice_channel(
name="{}'s private channel".format(owner.name),
overwrites=overwrites,
category=category
)
channelid = channel.id
except discord.HTTPException:
guild_settings.event_log.log(
description="Failed to create a private room for {}!".format(owner.mention),
colour=discord.Colour.red()
)
raise SafeCancellation("Couldn't create the private channel! Please try again later.")
# Add the new room to data
cls._table.create_row(
channelid=channelid,
guildid=guildid,
ownerid=ownerid
)
# Add the members to data, if any
if initial_members:
rented_members.insert_many(
*((channelid, member.id) for member in initial_members)
)
# Log the creation
guild_settings.event_log.log(
title="New private study room!",
description="Created a private study room for {} with:\n{}".format(
owner.mention,
', '.join(member.mention for member in initial_members)
)
)
# Create the room, schedule its expiry, and return
room = cls(channelid)
room.schedule()
return room
@classmethod
def fetch(cls, guildid, userid):
"""
Fetch a Room owned by a given member.
"""
return cls._rooms.get((guildid, userid), None)
@property
def data(self):
return self._table.fetch(self.key)
@property
def owner(self):
"""
The Member owning the room.
May be `None` if the member is no longer in the guild, or is otherwise not visible.
"""
guild = client.get_guild(self.data.guildid)
if guild:
return guild.get_member(self.data.ownerid)
@property
def channel(self):
"""
The Channel corresponding to this rented room.
May be `None` if the channel was already deleted.
"""
guild = client.get_guild(self.data.guildid)
if guild:
return guild.get_channel(self.key)
@property
def memberids(self):
"""
The list of memberids in the channel.
"""
return [row['userid'] for row in rented_members.select_where(channelid=self.key)]
@property
def timestamp(self):
"""
True unix timestamp for the room expiry time.
"""
return int(self.data.expires_at.replace(tzinfo=datetime.timezone.utc).timestamp())
def delete(self):
"""
Delete the room in an idempotent way.
"""
if self._task and not self._task.done():
self._task.cancel()
self._rooms.pop(self.map_key, None)
self._table.delete_where(channelid=self.key)
def schedule(self):
"""
Schedule this room to be expired.
"""
asyncio.create_task(self._schedule())
self._rooms[self.map_key] = self
async def _schedule(self):
"""
Expire the room after a sleep period.
"""
# Calculate time left
remaining = (self.data.expires_at - datetime.datetime.utcnow()).total_seconds()
# Create the waiting task and wait for it, accepting cancellation
self._task = asyncio.create_task(asyncio.sleep(remaining))
try:
await self._task
except asyncio.CancelledError:
return
await self._execute()
async def _execute(self):
"""
Expire the room.
"""
guild_settings = GuildSettings(self.data.guildid)
if self.channel:
# Delete the discord channel
try:
await self.channel.delete()
except discord.HTTPException:
pass
guild_settings.event_log.log(
title="Private study room expired!",
description="<@{}>'s private study room expired.".format(self.data.ownerid)
)
# Delete the room from data (cascades to member deletion)
self.delete()
async def add_members(self, *members):
guild_settings = GuildSettings(self.data.guildid)
# Update overwrites
overwrites = self.channel.overwrites
overwrites.update({member: self.member_overwrite for member in members})
try:
await self.channel.edit(overwrites=overwrites)
except discord.HTTPException:
guild_settings.event_log.log(
title="Failed to update study room permissions!",
description="An error occurred while adding the following users to the private room {}.\n{}".format(
self.channel.mention,
', '.join(member.mention for member in members)
),
colour=discord.Colour.red()
)
raise SafeCancellation("Sorry, something went wrong while adding the members!")
# Update data
rented_members.insert_many(
*((self.key, member.id) for member in members)
)
# Log
guild_settings.event_log.log(
title="New members added to private study room",
description="The following were added to {}.\n{}".format(
self.channel.mention,
', '.join(member.mention for member in members)
)
)
async def remove_members(self, *members):
guild_settings = GuildSettings(self.data.guildid)
if self.channel:
# Update overwrites
try:
await asyncio.gather(
*(self.channel.set_permissions(
member,
overwrite=None,
reason="Removing members from private channel.") for member in members)
)
except discord.HTTPException:
guild_settings.event_log.log(
title="Failed to update study room permissions!",
description=("An error occured while removing the "
"following members from the private room {}.\n{}").format(
self.channel.mention,
', '.join(member.mention for member in members)
),
colour=discord.Colour.red()
)
raise SafeCancellation("Sorry, something went wrong while removing those members!")
# Disconnect members if possible:
to_disconnect = set(self.channel.members).intersection(members)
try:
await asyncio.gather(
*(member.edit(voice_channel=None) for member in to_disconnect)
)
except discord.HTTPException:
pass
# Update data
rented_members.delete_where(channelid=self.key, userid=[member.id for member in members])
# Log
guild_settings.event_log.log(
title="Members removed from a private study room",
description="The following were removed from {}.\n{}".format(
self.channel.mention if self.channel else "`{}`".format(self.key),
', '.join(member.mention for member in members)
)
)
@module.launch_task
async def load_rented_rooms(client):
rows = rented.fetch_rows_where(guildid=THIS_SHARD)
for row in rows:
Room(row.channelid).schedule()
client.log(
"Loaded {} private study channels.".format(len(rows)),
context="LOAD_RENTED_ROOMS"
)
@client.add_after_event('member_join')
async def restore_room_permission(client, member):
"""
If a member has, or is part of, a private room when they rejoin, restore their permissions.
"""
# First check whether they own a room
owned = Room.fetch(member.guild.id, member.id)
if owned and owned.channel:
# Restore their room permissions
try:
await owned.channel.set_permissions(
member,
overwrite=Room.owner_overwrite
)
except discord.HTTPException:
pass
# Then check if they are in any other rooms
in_room_rows = rented_members.select_where(
_extra="LEFT JOIN rented USING (channelid) WHERE userid={} AND guildid={}".format(
member.id, member.guild.id
)
)
for row in in_room_rows:
room = Room.fetch(member.guild.id, row['ownerid'])
if room and row['ownerid'] != member.id and room.channel:
try:
await room.channel.set_permissions(
member,
overwrite=Room.member_overwrite
)
except discord.HTTPException:
pass

View File

@@ -0,0 +1,5 @@
from . import module
from . import data
from . import config
from . import commands

View File

@@ -0,0 +1,14 @@
from .module import module
@module.cmd(
name="sponsors",
group="Meta",
desc="Check out our wonderful partners!",
)
async def cmd_sponsors(ctx):
"""
Usage``:
{prefix}sponsors
"""
await ctx.reply(**ctx.client.settings.sponsor_message.args(ctx))

View File

@@ -0,0 +1,92 @@
from cmdClient.checks import is_owner
from settings import AppSettings, Setting, KeyValueData, ListData
from settings.setting_types import Message, String, GuildIDList
from meta import client
from core.data import app_config
from .data import guild_whitelist
@AppSettings.attach_setting
class sponsor_prompt(String, KeyValueData, Setting):
attr_name = 'sponsor_prompt'
_default = None
write_ward = is_owner
display_name = 'sponsor_prompt'
category = 'Sponsors'
desc = "Text to send after core commands to encourage checking `sponsors`."
long_desc = (
"Text posted after several commands to encourage users to check the `sponsors` command. "
"Occurences of `{{prefix}}` will be replaced by the bot prefix."
)
_quote = False
_table_interface = app_config
_id_column = 'appid'
_key_column = 'key'
_value_column = 'value'
_key = 'sponsor_prompt'
@classmethod
def _data_to_value(cls, id, data, **kwargs):
if data:
return data.replace("{prefix}", client.prefix)
else:
return None
@property
def success_response(self):
if self.value:
return "The sponsor prompt has been update."
else:
return "The sponsor prompt has been cleared."
@AppSettings.attach_setting
class sponsor_message(Message, KeyValueData, Setting):
attr_name = 'sponsor_message'
_default = '{"content": "Coming Soon!"}'
write_ward = is_owner
display_name = 'sponsor_message'
category = 'Sponsors'
desc = "`sponsors` command response."
long_desc = (
"Message to reply with when a user runs the `sponsors` command."
)
_table_interface = app_config
_id_column = 'appid'
_key_column = 'key'
_value_column = 'value'
_key = 'sponsor_message'
_cmd_str = "{prefix}sponsors --edit"
@property
def success_response(self):
return "The `sponsors` command message has been updated."
@AppSettings.attach_setting
class sponsor_guild_whitelist(GuildIDList, ListData, Setting):
attr_name = 'sponsor_guild_whitelist'
write_ward = is_owner
category = 'Sponsors'
display_name = 'sponsor_hidden_in'
desc = "Guilds where the sponsor prompt is not displayed."
long_desc = (
"A list of guilds where the sponsor prompt hint will be hidden (see the `sponsor_prompt` setting)."
)
_table_interface = guild_whitelist
_id_column = 'appid'
_data_column = 'guildid'
_force_unique = True

View File

@@ -0,0 +1,4 @@
from data import Table
guild_whitelist = Table("sponsor_guild_whitelist")

View File

@@ -0,0 +1,33 @@
import discord
from LionModule import LionModule
from LionContext import LionContext
from meta import client
module = LionModule("Sponsor")
sponsored_commands = {'profile', 'stats', 'weekly', 'monthly'}
@LionContext.reply.add_wrapper
async def sponsor_reply_wrapper(func, ctx: LionContext, *args, **kwargs):
if ctx.cmd and ctx.cmd.name in sponsored_commands:
if (prompt := ctx.client.settings.sponsor_prompt.value):
if ctx.guild:
show = ctx.guild.id not in ctx.client.settings.sponsor_guild_whitelist.value
show = show and not ctx.client.data.premium_guilds.queries.fetch_guild(ctx.guild.id)
else:
show = True
if show:
sponsor_hint = discord.Embed(
description=prompt,
colour=discord.Colour.dark_theme()
)
if 'embed' not in kwargs:
kwargs['embed'] = sponsor_hint
return await func(ctx, *args, **kwargs)

View File

@@ -0,0 +1,9 @@
# flake8: noqa
from .module import module
from . import data
from . import profile
from . import setprofile
from . import top_cmd
from . import goals
from . import achievements

View File

@@ -0,0 +1,485 @@
from typing import NamedTuple, Optional, Union
from datetime import timedelta
import pytz
import discord
from cmdClient.checks import in_guild
from LionContext import LionContext
from meta import client, conf
from core import Lion
from data.conditions import NOTNULL, LEQ
from utils.lib import utc_now
from modules.topgg.utils import topgg_upvote_link
from .module import module
class AchievementLevel(NamedTuple):
name: str
threshold: Union[int, float]
emoji: discord.PartialEmoji
class Achievement:
"""
ABC for a member or user achievement.
"""
# Name of the achievement
name: str = None
subtext: str = None
congrats_text: str = "Congratulations, you completed this challenge!"
# List of levels for the achievement. Must always contain a 0 level!
levels: list[AchievementLevel] = None
def __init__(self, guildid: int, userid: int):
self.guildid = guildid
self.userid = userid
# Current status of the achievement. None until calculated by `update`.
self.value: int = None
# Current level index in levels. None until calculated by `update`.
self.level_id: int = None
@staticmethod
def progress_bar(value, minimum, maximum, width=10) -> str:
"""
Build a text progress bar representing `value` between `minimum` and `maximum`.
"""
emojis = conf.emojis
proportion = (value - minimum) / (maximum - minimum)
sections = min(max(int(proportion * width), 0), width)
bar = []
# Starting segment
bar.append(str(emojis.progress_left_empty) if sections == 0 else str(emojis.progress_left_full))
# Full segments up to transition or end
if sections >= 2:
bar.append(str(emojis.progress_middle_full) * (sections - 2))
# Transition, if required
if 1 < sections < width:
bar.append(str(emojis.progress_middle_transition))
# Empty sections up to end
if sections < width:
bar.append(str(emojis.progress_middle_empty) * (width - max(sections, 1) - 1))
# End section
bar.append(str(emojis.progress_right_empty) if sections < width else str(emojis.progress_right_full))
# Join all the sections together and return
return ''.join(bar)
@property
def progress_text(self) -> str:
"""
A brief textual description of the current progress.
Intended to be overridden by achievement implementations.
"""
return f"{int(self.value)}/{self.next_level.threshold if self.next_level else self.level.threshold}"
def progress_field(self) -> tuple[str, str]:
"""
Builds the progress field for the achievement display.
"""
# TODO: Not adjusted for levels
# TODO: Add hint if progress is empty?
name = f"{self.levels[1].emoji} {self.name} ({self.progress_text})"
value = "**0** {progress_bar} **{threshold}**\n*{subtext}*".format(
subtext=(self.subtext if self.next_level else self.congrats_text) or '',
progress_bar=self.progress_bar(self.value, self.levels[0].threshold, self.levels[1].threshold),
threshold=self.levels[1].threshold
)
return (name, value)
@classmethod
async def fetch(cls, guildid: int, userid: int) -> 'Achievement':
"""
Fetch an Achievement status for the given member.
"""
return await cls(guildid, userid).update()
@property
def level(self) -> AchievementLevel:
"""
The current `AchievementLevel` for this member achievement.
"""
if self.level_id is None:
raise ValueError("Cannot obtain level before first update!")
return self.levels[self.level_id]
@property
def next_level(self) -> Optional[AchievementLevel]:
"""
The next `AchievementLevel` for this member achievement,
or `None` if it is at the maximum level.
"""
if self.level_id is None:
raise ValueError("Cannot obtain level before first update!")
if self.level_id == len(self.levels) - 1:
return None
else:
return self.levels[self.level_id + 1]
async def update(self) -> 'Achievement':
"""
Calculate and store the current member achievement status.
Returns `self` for easy chaining.
"""
# First fetch the value
self.value = await self._calculate_value()
# Then determine the current level
# Using 0 as a fallback in case the value is negative
self.level_id = next(
(i for i, level in reversed(list(enumerate(self.levels))) if level.threshold <= self.value),
0
)
# And return `self` for chaining
return self
async def _calculate_value(self) -> Union[int, float]:
"""
Calculate the current `value` of the member achievement.
Must be overridden by Achievement implementations.
"""
raise NotImplementedError
class Workout(Achievement):
sorting_index = 8
emoji_index = 4
name = "It's about Power"
subtext = "Workout 50 times"
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 50, conf.emojis.active_achievement_4),
]
async def _calculate_value(self) -> int:
"""
Returns the total number of workouts from this user.
"""
return client.data.workout_sessions.select_one_where(
userid=self.userid,
select_columns="COUNT(*)"
)[0]
class StudyHours(Achievement):
sorting_index = 1
emoji_index = 1
name = "Dream Big"
subtext = "Study a total of 1000 hours"
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 1000, conf.emojis.active_achievement_1),
]
async def _calculate_value(self) -> float:
"""
Returns the total number of hours this user has studied.
"""
past_session_total = client.data.session_history.select_one_where(
userid=self.userid,
select_columns="SUM(duration)"
)[0] or 0
current_session_total = client.data.current_sessions.select_one_where(
userid=self.userid,
select_columns="SUM(EXTRACT(EPOCH FROM (NOW() - start_time)))"
)[0] or 0
session_total = past_session_total + current_session_total
hours = session_total / 3600
return hours
class StudyStreak(Achievement):
sorting_index = 2
emoji_index = 2
name = "Consistency is Key"
subtext = "Reach a 100-day study streak"
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 100, conf.emojis.active_achievement_2)
]
async def _calculate_value(self) -> int:
"""
Return the user's maximum global study streak.
"""
lion = Lion.fetch(self.guildid, self.userid)
history = client.data.session_history.select_where(
userid=self.userid,
select_columns=(
"start_time",
"(start_time + duration * interval '1 second') AS end_time"
),
_extra="ORDER BY start_time DESC"
)
# Streak statistics
streak = 0
max_streak = 0
day_attended = True if 'sessions' in client.objects and lion.session else None
date = lion.day_start
daydiff = timedelta(days=1)
periods = [(row['start_time'], row['end_time']) for row in history]
i = 0
while i < len(periods):
row = periods[i]
i += 1
if row[1] > date:
# They attended this day
day_attended = True
continue
elif day_attended is None:
# Didn't attend today, but don't break streak
day_attended = False
date -= daydiff
i -= 1
continue
elif not day_attended:
# Didn't attend the day, streak broken
date -= daydiff
i -= 1
pass
else:
# Attended the day
streak += 1
# Move window to the previous day and try the row again
day_attended = False
prev_date = date
date -= daydiff
i -= 1
# Special case, when the last session started in the previous day
# Then the day is already attended
if i > 1 and date < periods[i-2][0] <= prev_date:
day_attended = True
continue
max_streak = max(max_streak, streak)
streak = 0
# Handle loop exit state, i.e. the last streak
if day_attended:
streak += 1
max_streak = max(max_streak, streak)
return max_streak
class Voting(Achievement):
sorting_index = 7
emoji_index = 7
name = "We're a Team"
subtext = "[Vote]({}) 100 times on top.gg".format(topgg_upvote_link)
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 100, conf.emojis.active_achievement_7)
]
async def _calculate_value(self) -> int:
"""
Returns the number of times the user has voted for the bot.
"""
return client.data.topgg.select_one_where(
userid=self.userid,
select_columns="COUNT(*)"
)[0]
class DaysStudying(Achievement):
sorting_index = 3
emoji_index = 3
name = "Aim For The Moon"
subtext = "Study on 90 different days"
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 90, conf.emojis.active_achievement_3)
]
async def _calculate_value(self) -> int:
"""
Returns the number of days the user has studied in total.
"""
lion = Lion.fetch(self.guildid, self.userid)
offset = int(lion.day_start.utcoffset().total_seconds())
with client.data.session_history.conn as conn:
cursor = conn.cursor()
# TODO: Consider DST offset.
cursor.execute(
"""
SELECT
COUNT(DISTINCT(date_trunc('day', (time AT TIME ZONE 'utc') + interval '{} seconds')))
FROM (
(SELECT start_time AS time FROM session_history WHERE userid=%s)
UNION
(SELECT (start_time + duration * interval '1 second') AS time FROM session_history WHERE userid=%s)
) AS times;
""".format(offset),
(self.userid, self.userid)
)
data = cursor.fetchone()
return data[0]
class TasksComplete(Achievement):
sorting_index = 4
emoji_index = 8
name = "One Step at a Time"
subtext = "Complete 1000 tasks"
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 1000, conf.emojis.active_achievement_8)
]
async def _calculate_value(self) -> int:
"""
Returns the number of tasks the user has completed.
"""
return client.data.tasklist.select_one_where(
userid=self.userid,
completed_at=NOTNULL,
select_columns="COUNT(*)"
)[0]
class ScheduledSessions(Achievement):
sorting_index = 5
emoji_index = 5
name = "Be Accountable"
subtext = "Attend 500 scheduled sessions"
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 500, conf.emojis.active_achievement_5)
]
async def _calculate_value(self) -> int:
"""
Returns the number of scheduled sesions the user has attended.
"""
return client.data.accountability_member_info.select_one_where(
userid=self.userid,
start_at=LEQ(utc_now()),
select_columns="COUNT(*)",
_extra="AND (duration > 0 OR last_joined_at IS NOT NULL)"
)[0]
class MonthlyHours(Achievement):
sorting_index = 6
emoji_index = 6
name = "The 30 Days Challenge"
subtext = "Study 100 hours in 30 days"
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 100, conf.emojis.active_achievement_6)
]
async def _calculate_value(self) -> float:
"""
Returns the maximum number of hours the user has studied in a month.
"""
# Get the first session so we know how far back to look
first_session = client.data.session_history.select_one_where(
userid=self.userid,
select_columns="MIN(start_time)"
)[0]
# Get the user's timezone
lion = Lion.fetch(self.guildid, self.userid)
# If the first session doesn't exist, simulate an existing session (to avoid an extra lookup)
first_session = first_session or lion.day_start - timedelta(days=1)
# Build the list of month start timestamps
month_start = lion.day_start.replace(day=1)
months = [month_start.astimezone(pytz.utc)]
while month_start >= first_session:
month_start -= timedelta(days=1)
month_start = month_start.replace(day=1)
months.append(month_start.astimezone(pytz.utc))
# Query the study times
data = client.data.session_history.queries.study_times_since(
self.guildid, self.userid, *months
)
cumulative_times = [row[0] or 0 for row in data]
times = [nxt - crt for nxt, crt in zip(cumulative_times[1:], cumulative_times[0:])]
max_time = max(cumulative_times[0], *times) if len(months) > 1 else cumulative_times[0]
return max_time / 3600
# Define the displayed achivement order
achievements = [
Workout,
StudyHours,
StudyStreak,
Voting,
DaysStudying,
TasksComplete,
ScheduledSessions,
MonthlyHours
]
async def get_achievements_for(member, panel_sort=False):
status = [
await ach.fetch(member.guild.id, member.id)
for ach in sorted(achievements, key=lambda cls: (cls.sorting_index if panel_sort else cls.emoji_index))
]
return status
@module.cmd(
name="achievements",
desc="View your progress towards the achievements!",
group="Statistics",
)
@in_guild()
async def cmd_achievements(ctx: LionContext):
"""
Usage``:
{prefix}achievements
Description:
View your progress towards attaining the achievement badges shown on your `profile`.
"""
status = await get_achievements_for(ctx.author, panel_sort=True)
embed = discord.Embed(
title="Achievements",
colour=discord.Colour.orange()
)
for achievement in status:
name, value = achievement.progress_field()
embed.add_field(
name=name, value=value, inline=False
)
await ctx.reply(embed=embed)

View File

@@ -0,0 +1,39 @@
from cachetools import TTLCache
from data import Table, RowTable
profile_tags = Table('member_profile_tags', attach_as='profile_tags')
@profile_tags.save_query
def get_tags_for(guildid, userid):
rows = profile_tags.select_where(
guildid=guildid, userid=userid,
_extra="ORDER BY tagid ASC"
)
return [row['tag'] for row in rows]
weekly_goals = RowTable(
'member_weekly_goals',
('guildid', 'userid', 'weekid', 'study_goal', 'task_goal'),
('guildid', 'userid', 'weekid'),
cache=TTLCache(5000, 60 * 60 * 24),
attach_as='weekly_goals'
)
# NOTE: Not using a RowTable here since these will almost always be mass-selected
weekly_tasks = Table('member_weekly_goal_tasks')
monthly_goals = RowTable(
'member_monthly_goals',
('guildid', 'userid', 'monthid', 'study_goal', 'task_goal'),
('guildid', 'userid', 'monthid'),
cache=TTLCache(5000, 60 * 60 * 24),
attach_as='monthly_goals'
)
monthly_tasks = Table('member_monthly_goal_tasks')

View File

@@ -0,0 +1,332 @@
"""
Weekly and Monthly goal display and edit interface.
"""
from enum import Enum
import discord
from cmdClient.checks import in_guild
from cmdClient.lib import SafeCancellation
from utils.lib import parse_ranges
from .module import module
from .data import weekly_goals, weekly_tasks, monthly_goals, monthly_tasks
MAX_LENGTH = 200
MAX_TASKS = 10
class GoalType(Enum):
WEEKLY = 0
MONTHLY = 1
def index_range_parser(userstr, max):
try:
indexes = parse_ranges(userstr)
except SafeCancellation:
raise SafeCancellation(
"Couldn't parse the provided task ids! "
"Please list the task numbers or ranges separated by a comma, e.g. `0, 2-4`."
) from None
return [index for index in indexes if index <= max]
@module.cmd(
"weeklygoals",
group="Statistics",
desc="Set your weekly goals and view your progress!",
aliases=('weeklygoal',),
flags=('study=', 'tasks=')
)
@in_guild()
async def cmd_weeklygoals(ctx, flags):
"""
Usage``:
{prefix}weeklygoals [--study <hours>] [--tasks <number>]
{prefix}weeklygoals add <task>
{prefix}weeklygoals edit <taskid> <new task>
{prefix}weeklygoals check <taskids>
{prefix}weeklygoals remove <taskids>
Description:
Set yourself up to `10` goals for this week and keep yourself accountable!
Use `add/edit/check/remove` to edit your goals, similarly to `{prefix}todo`.
You can also add multiple tasks at once by writing them on multiple lines.
You can also track your progress towards a number of hours studied with `--study`, \
and aim for a number of tasks completed with `--tasks`.
Run the command with no arguments or check your profile to see your progress!
Examples``:
{prefix}weeklygoals add Read chapters 1 to 10.
{prefix}weeklygoals check 1
{prefix}weeklygoals --study 48h --tasks 60
"""
await goals_command(ctx, flags, GoalType.WEEKLY)
@module.cmd(
"monthlygoals",
group="Statistics",
desc="Set your monthly goals and view your progress!",
aliases=('monthlygoal',),
flags=('study=', 'tasks=')
)
@in_guild()
async def cmd_monthlygoals(ctx, flags):
"""
Usage``:
{prefix}monthlygoals [--study <hours>] [--tasks <number>]
{prefix}monthlygoals add <task>
{prefix}monthlygoals edit <taskid> <new task>
{prefix}monthlygoals check <taskids>
{prefix}monthlygoals uncheck <taskids>
{prefix}monthlygoals remove <taskids>
Description:
Set yourself up to `10` goals for this month and keep yourself accountable!
Use `add/edit/check/remove` to edit your goals, similarly to `{prefix}todo`.
You can also add multiple tasks at once by writing them on multiple lines.
You can also track your progress towards a number of hours studied with `--study`, \
and aim for a number of tasks completed with `--tasks`.
Run the command with no arguments or check your profile to see your progress!
Examples``:
{prefix}monthlygoals add Read chapters 1 to 10.
{prefix}monthlygoals check 1
{prefix}monthlygoals --study 180h --tasks 60
"""
await goals_command(ctx, flags, GoalType.MONTHLY)
async def goals_command(ctx, flags, goal_type):
prefix = ctx.best_prefix
if goal_type == GoalType.WEEKLY:
name = 'week'
goal_table = weekly_goals
task_table = weekly_tasks
rowkey = 'weekid'
rowid = ctx.alion.week_timestamp
tasklist = task_table.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
weekid=rowid,
_extra="ORDER BY taskid ASC"
)
max_time = 7 * 16
else:
name = 'month'
goal_table = monthly_goals
task_table = monthly_tasks
rowid = ctx.alion.month_timestamp
rowkey = 'monthid'
tasklist = task_table.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
monthid=rowid,
_extra="ORDER BY taskid ASC"
)
max_time = 31 * 16
# We ensured the `lion` existed with `ctx.alion` above
# This also ensures a new tasklist can reference the period member goal key
# TODO: Should creation copy the previous existing week?
goal_row = goal_table.fetch_or_create((ctx.guild.id, ctx.author.id, rowid))
if flags['study']:
# Set study hour goal
time = flags['study'].lower().strip('h ')
if not time or not time.isdigit():
return await ctx.error_reply(
f"Please provide your {name}ly study goal in hours!\n"
f"For example, `{prefix}{ctx.alias} --study 48h`"
)
hours = int(time)
if hours > max_time:
return await ctx.error_reply(
"You can't set your goal this high! Please rest and keep a healthy lifestyle."
)
goal_row.study_goal = hours
if flags['tasks']:
# Set tasks completed goal
count = flags['tasks']
if not count or not count.isdigit():
return await ctx.error_reply(
f"Please provide the number of tasks you want to complete this {name}!\n"
f"For example, `{prefix}{ctx.alias} --tasks 300`"
)
if int(count) > 2048:
return await ctx.error_reply(
"Your task goal is too high!"
)
goal_row.task_goal = int(count)
if ctx.args:
# If there are arguments, assume task/goal management
# Extract the command if it exists, assume add operation if it doesn't
splits = ctx.args.split(maxsplit=1)
cmd = splits[0].lower().strip()
args = splits[1].strip() if len(splits) > 1 else ''
if cmd in ('check', 'done', 'complete'):
if not args:
# Show subcommand usage
return await ctx.error_reply(
f"**Usage:**`{prefix}{ctx.alias} check <taskids>`\n"
f"**Example:**`{prefix}{ctx.alias} check 0, 2-4`"
)
if (indexes := index_range_parser(args, len(tasklist) - 1)):
# Check the given indexes
# If there are no valid indexes given, just do nothing and fall out to showing the goals
task_table.update_where(
{'completed': True},
taskid=[tasklist[index]['taskid'] for index in indexes]
)
elif cmd in ('uncheck', 'undone', 'uncomplete'):
if not args:
# Show subcommand usage
return await ctx.error_reply(
f"**Usage:**`{prefix}{ctx.alias} uncheck <taskids>`\n"
f"**Example:**`{prefix}{ctx.alias} uncheck 0, 2-4`"
)
if (indexes := index_range_parser(args, len(tasklist) - 1)):
# Check the given indexes
# If there are no valid indexes given, just do nothing and fall out to showing the goals
task_table.update_where(
{'completed': False},
taskid=[tasklist[index]['taskid'] for index in indexes]
)
elif cmd in ('remove', 'delete', '-', 'rm'):
if not args:
# Show subcommand usage
return await ctx.error_reply(
f"**Usage:**`{prefix}{ctx.alias} remove <taskids>`\n"
f"**Example:**`{prefix}{ctx.alias} remove 0, 2-4`"
)
if (indexes := index_range_parser(args, len(tasklist) - 1)):
# Delete the given indexes
# If there are no valid indexes given, just do nothing and fall out to showing the goals
task_table.delete_where(
taskid=[tasklist[index]['taskid'] for index in indexes]
)
elif cmd == 'edit':
if not args or len(splits := args.split(maxsplit=1)) < 2 or not splits[0].isdigit():
# Show subcommand usage
return await ctx.error_reply(
f"**Usage:**`{prefix}{ctx.alias} edit <taskid> <edited task>`\n"
f"**Example:**`{prefix}{ctx.alias} edit 2 Fix the scond task`"
)
index = int(splits[0])
new_content = splits[1].strip()
if index >= len(tasklist):
return await ctx.error_reply(
f"Task `{index}` doesn't exist to edit!"
)
if len(new_content) > MAX_LENGTH:
return await ctx.error_reply(
f"Please keep your goals under `{MAX_LENGTH}` characters long."
)
# Passed all checks, edit task
task_table.update_where(
{'content': new_content},
taskid=tasklist[index]['taskid']
)
else:
# Extract the tasks to add
if cmd in ('add', '+'):
if not args:
# Show subcommand usage
return await ctx.error_reply(
f"**Usage:**`{prefix}{ctx.alias} [add] <new task>`\n"
f"**Example:**`{prefix}{ctx.alias} add Read the Studylion help pages.`"
)
else:
args = ctx.args
tasks = args.splitlines()
# Check count
if len(tasklist) + len(tasks) > MAX_TASKS:
return await ctx.error_reply(
f"You can have at most **{MAX_TASKS}** {name}ly goals!"
)
# Check length
if any(len(task) > MAX_LENGTH for task in tasks):
return await ctx.error_reply(
f"Please keep your goals under `{MAX_LENGTH}` characters long."
)
# We passed the checks, add the tasks
to_insert = [
(ctx.guild.id, ctx.author.id, rowid, task)
for task in tasks
]
task_table.insert_many(
*to_insert,
insert_keys=('guildid', 'userid', rowkey, 'content')
)
elif not any((goal_row.study_goal, goal_row.task_goal, tasklist)):
# The user hasn't set any goals for this time period
# Prompt them with information about how to set a goal
embed = discord.Embed(
colour=discord.Colour.orange(),
title=f"**You haven't set any goals for this {name} yet! Try the following:**\n"
)
embed.add_field(
name="Aim for a number of study hours with",
value=f"`{prefix}{ctx.alias} --study 48h`"
)
embed.add_field(
name="Aim for a number of tasks completed with",
value=f"`{prefix}{ctx.alias} --tasks 300`",
inline=False
)
embed.add_field(
name=f"Set up to 10 custom goals for the {name}!",
value=(
f"`{prefix}{ctx.alias} add Write a 200 page thesis.`\n"
f"`{prefix}{ctx.alias} edit 1 Write 2 pages of the 200 page thesis.`\n"
f"`{prefix}{ctx.alias} done 0, 1, 3-4`\n"
f"`{prefix}{ctx.alias} delete 2-4`"
),
inline=False
)
return await ctx.reply(embed=embed)
# Show the goals
if goal_type == GoalType.WEEKLY:
await display_weekly_goals_for(ctx)
else:
await display_monthly_goals_for(ctx)
async def display_weekly_goals_for(ctx):
"""
Display the user's weekly goal summary and progress towards them
TODO: Currently a stub, since the system is overidden by the GUI plugin
"""
# Collect data
lion = ctx.alion
rowid = lion.week_timestamp
goals = weekly_goals.fetch_or_create((ctx.guild.id, ctx.author.id, rowid))
tasklist = weekly_tasks.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
weekid=rowid
)
...
async def display_monthly_goals_for(ctx):
...

View File

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

View File

@@ -0,0 +1,266 @@
from datetime import datetime, timedelta
import discord
from cmdClient.checks import in_guild
from utils.lib import prop_tabulate, utc_now
from data import tables
from data.conditions import LEQ
from core import Lion
from modules.study.tracking.data import session_history
from .module import module
@module.cmd(
"stats",
group="Statistics",
desc="View your personal server study statistics!",
aliases=('profile',),
allow_before_ready=True
)
@in_guild()
async def cmd_stats(ctx):
"""
Usage``:
{prefix}stats
{prefix}stats <user mention>
Description:
View the study statistics for yourself or the mentioned user.
"""
# Identify the target
if ctx.args:
if not ctx.msg.mentions:
return await ctx.error_reply("Please mention a user to view their statistics!")
target = ctx.msg.mentions[0]
else:
target = ctx.author
# System sync
Lion.sync()
# Fetch the required data
lion = Lion.fetch(ctx.guild.id, target.id)
history = session_history.select_where(
guildid=ctx.guild.id,
userid=target.id,
select_columns=(
"start_time",
"(start_time + duration * interval '1 second') AS end_time"
),
_extra="ORDER BY start_time DESC"
)
# Current economy balance (accounting for current session)
coins = lion.coins
season_time = lion.time
workout_total = lion.data.workout_count
# Leaderboard ranks
exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members)
exclude.update(ctx.client.user_blacklist())
exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id])
if target.id in exclude:
time_rank = None
coin_rank = None
else:
time_rank, coin_rank = tables.lions.queries.get_member_rank(ctx.guild.id, target.id, list(exclude or [0]))
# Study time
# First get the all/month/week/day timestamps
day_start = lion.day_start
period_timestamps = (
datetime(1970, 1, 1),
day_start.replace(day=1),
day_start - timedelta(days=day_start.weekday()),
day_start
)
study_times = [0, 0, 0, 0]
for i, timestamp in enumerate(period_timestamps):
study_time = tables.session_history.queries.study_time_since(ctx.guild.id, target.id, timestamp)
if not study_time:
# So we don't make unecessary database calls
break
study_times[i] = study_time
# Streak statistics
streak = 0
current_streak = None
max_streak = 0
day_attended = True if 'sessions' in ctx.client.objects and lion.session else None
date = day_start
daydiff = timedelta(days=1)
periods = [(row['start_time'], row['end_time']) for row in history]
i = 0
while i < len(periods):
row = periods[i]
i += 1
if row[1] > date:
# They attended this day
day_attended = True
continue
elif day_attended is None:
# Didn't attend today, but don't break streak
day_attended = False
date -= daydiff
i -= 1
continue
elif not day_attended:
# Didn't attend the day, streak broken
date -= daydiff
i -= 1
pass
else:
# Attended the day
streak += 1
# Move window to the previous day and try the row again
day_attended = False
prev_date = date
date -= daydiff
i -= 1
# Special case, when the last session started in the previous day
# Then the day is already attended
if i > 1 and date < periods[i-2][0] <= prev_date:
day_attended = True
continue
max_streak = max(max_streak, streak)
if current_streak is None:
current_streak = streak
streak = 0
# Handle loop exit state, i.e. the last streak
if day_attended:
streak += 1
max_streak = max(max_streak, streak)
if current_streak is None:
current_streak = streak
# Accountability stats
accountability = tables.accountability_member_info.select_where(
userid=target.id,
start_at=LEQ(utc_now()),
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
_extra="ORDER BY start_at DESC"
)
if len(accountability):
acc_duration = sum(row['duration'] for row in accountability)
acc_attended = sum(row['attended'] for row in accountability)
acc_total = len(accountability)
acc_rate = (acc_attended * 100) / acc_total
else:
acc_duration = 0
acc_rate = 0
# Study League
guild_badges = tables.study_badges.fetch_rows_where(guildid=ctx.guild.id)
if lion.data.last_study_badgeid:
current_badge = tables.study_badges.fetch(lion.data.last_study_badgeid)
else:
current_badge = None
next_badge = min(
(badge for badge in guild_badges
if badge.required_time > (current_badge.required_time if current_badge else 0)),
key=lambda badge: badge.required_time,
default=None
)
# We have all the data
# Now start building the embed
embed = discord.Embed(
colour=discord.Colour.orange(),
title="Study Profile for {}".format(str(target))
)
embed.set_thumbnail(url=target.avatar_url)
# Add studying since if they have studied
if history:
embed.set_footer(text="Studying Since")
embed.timestamp = history[-1]['start_time']
# Set the description based on season time and server rank
if season_time:
time_str = "**{}:{:02}**".format(
season_time // 3600,
(season_time // 60) % 60
)
if time_rank is None:
rank_str = None
elif time_rank == 1:
rank_str = "1st"
elif time_rank == 2:
rank_str = "2nd"
elif time_rank == 3:
rank_str = "3rd"
else:
rank_str = "{}th".format(time_rank)
embed.description = "{} has studied for **{}**{}{}".format(
target.mention,
time_str,
" this season" if study_times[0] - season_time > 60 else "",
", and is ranked **{}** in the server!".format(rank_str) if rank_str else "."
)
else:
embed.description = "{} hasn't studied in this server yet!".format(target.mention)
# Build the stats table
stats = {}
stats['Coins Earned'] = "**{}** LC".format(
coins,
# "Rank `{}`".format(coin_rank) if coins and coin_rank else "Unranked"
)
if workout_total:
stats['Workouts'] = "**{}** sessions".format(workout_total)
if acc_duration:
stats['Accountability'] = "**{}** hours (`{:.0f}%` attended)".format(
acc_duration // 3600,
acc_rate
)
stats['Study Streak'] = "**{}** days{}".format(
current_streak,
" (longest **{}** days)".format(max_streak) if max_streak else ''
)
stats_table = prop_tabulate(*zip(*stats.items()))
# Build the time table
time_table = prop_tabulate(
('Daily', 'Weekly', 'Monthly', 'All Time'),
["{:02}:{:02}".format(t // 3600, (t // 60) % 60) for t in reversed(study_times)]
)
# Populate the embed
embed.add_field(name="Study Time", value=time_table)
embed.add_field(name="Statistics", value=stats_table)
# Add the study league field
if current_badge or next_badge:
current_str = (
"You are currently in <@&{}>!".format(current_badge.roleid) if current_badge else "No league yet!"
)
if next_badge:
needed = max(next_badge.required_time - season_time, 0)
next_str = "Study for **{:02}:{:02}** more to achieve <@&{}>.".format(
needed // 3600,
(needed // 60) % 60,
next_badge.roleid
)
else:
next_str = "You have reached the highest league! Congratulations!"
embed.add_field(
name="Study League",
value="{}\n{}".format(current_str, next_str),
inline=False
)
await ctx.reply(embed=embed)

View File

@@ -0,0 +1,227 @@
"""
Provides a command to update a member's profile badges.
"""
import string
import discord
from cmdClient.lib import SafeCancellation
from cmdClient.checks import in_guild
from wards import guild_moderator
from .data import profile_tags
from .module import module
MAX_TAGS = 10
MAX_LENGTH = 30
@module.cmd(
"setprofile",
group="Personal Settings",
desc="Set or update your study profile tags.",
aliases=('editprofile', 'mytags'),
flags=('clear', 'for')
)
@in_guild()
async def cmd_setprofile(ctx, flags):
"""
Usage``:
{prefix}setprofile <tag>, <tag>, <tag>, ...
{prefix}setprofile <id> <new tag>
{prefix}setprofile --clear [--for @user]
Description:
Set or update the tags appearing in your study server profile.
Moderators can clear a user's tags with `--clear --for @user`.
Examples``:
{prefix}setprofile Mathematics, Bioloyg, Medicine, Undergraduate, Europe
{prefix}setprofile 2 Biology
{prefix}setprofile --clear
"""
if flags['clear']:
if flags['for']:
# Moderator-clearing a user's tags
# First check moderator permissions
if not await guild_moderator.run(ctx):
return await ctx.error_reply(
"You need to be a server moderator to use this!"
)
# Check input and extract users to clear for
if not (users := ctx.msg.mentions):
# Show moderator usage
return await ctx.error_reply(
f"**Usage:** `{ctx.best_prefix}setprofile --clear --for @user`\n"
f"**Example:** {ctx.best_prefix}setprofile --clear --for {ctx.author.mention}"
)
# Clear the tags
profile_tags.delete_where(
guildid=ctx.guild.id,
userid=[user.id for user in users]
)
# Ack the moderator
await ctx.embed_reply(
"Profile tags cleared!"
)
else:
# The author wants to clear their own tags
# First delete the tags, save the rows for reporting
rows = profile_tags.delete_where(
guildid=ctx.guild.id,
userid=ctx.author.id
)
# Ack the user
if not rows:
await ctx.embed_reply(
"You don't have any profile tags to clear!"
)
else:
embed = discord.Embed(
colour=discord.Colour.green(),
description="Successfully cleared your profile!"
)
embed.add_field(
name="Removed tags",
value='\n'.join(row['tag'].upper() for row in rows)
)
await ctx.reply(embed=embed)
elif ctx.args:
if len(splits := ctx.args.split(maxsplit=1)) > 1 and splits[0].isdigit():
# Assume we are editing the provided id
tagid = int(splits[0])
if tagid > MAX_TAGS:
return await ctx.error_reply(
f"Sorry, you can have a maximum of `{MAX_TAGS}` tags!"
)
if tagid == 0:
return await ctx.error_reply("Tags start at `1`!")
# Retrieve the user's current taglist
rows = profile_tags.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
_extra="ORDER BY tagid ASC"
)
# Parse and validate provided new content
content = splits[1].strip().upper()
validate_tag(content)
if tagid > len(rows):
# Trying to edit a tag that doesn't exist yet
# Just create it instead
profile_tags.insert(
guildid=ctx.guild.id,
userid=ctx.author.id,
tag=content
)
# Ack user
await ctx.reply(
embed=discord.Embed(title="Tag created!", colour=discord.Colour.green())
)
else:
# Get the row id to update
to_edit = rows[tagid - 1]['tagid']
# Update the tag
profile_tags.update_where(
{'tag': content},
tagid=to_edit
)
# Ack user
embed = discord.Embed(
colour=discord.Colour.green(),
title="Tag updated!"
)
await ctx.reply(embed=embed)
else:
# Assume the arguments are a comma separated list of badges
# Parse and validate
to_add = [split.strip().upper() for line in ctx.args.splitlines() for split in line.split(',')]
to_add = [split.replace('<3', '❤️') for split in to_add if split]
if not to_add:
return await ctx.error_reply("No valid tags given, nothing to do!")
validate_tag(*to_add)
if len(to_add) > MAX_TAGS:
return await ctx.error_reply(f"You can have a maximum of {MAX_TAGS} tags!")
# Remove the existing badges
deleted_rows = profile_tags.delete_where(
guildid=ctx.guild.id,
userid=ctx.author.id
)
# Insert the new tags
profile_tags.insert_many(
*((ctx.guild.id, ctx.author.id, tag) for tag in to_add),
insert_keys=('guildid', 'userid', 'tag')
)
# Ack with user
embed = discord.Embed(
colour=discord.Colour.green(),
title="Profile tags updated!"
)
embed.add_field(
name="New tags",
value='\n'.join(to_add)
)
if deleted_rows:
embed.add_field(
name="Replaced tags",
value='\n'.join(row['tag'].upper() for row in deleted_rows),
inline=False
)
if len(to_add) == 1:
embed.set_footer(
text=f"TIP: Add multiple tags with {ctx.best_prefix}setprofile tag1, tag2, ..."
)
await ctx.reply(embed=embed)
else:
# No input was provided
# Show usage and exit
embed = discord.Embed(
colour=discord.Colour.red(),
description=(
"Edit your study profile "
"tags so other people can see what you do!"
)
)
embed.add_field(
name="Usage",
value=(
f"`{ctx.best_prefix}setprofile <tag>, <tag>, <tag>, ...`\n"
f"`{ctx.best_prefix}setprofile <id> <new tag>`"
)
)
embed.add_field(
name="Examples",
value=(
f"`{ctx.best_prefix}setprofile Mathematics, Bioloyg, Medicine, Undergraduate, Europe`\n"
f"`{ctx.best_prefix}setprofile 2 Biology`"
),
inline=False
)
await ctx.reply(embed=embed)
def validate_tag(*content):
for content in content:
if not set(content.replace('❤️', '')).issubset(string.printable):
raise SafeCancellation(
f"Invalid tag `{content}`!\n"
"Tags may only contain alphanumeric and punctuation characters."
)
if len(content) > MAX_LENGTH:
raise SafeCancellation(
f"Provided tag is too long! Please keep your tags shorter than {MAX_LENGTH} characters."
)

View File

@@ -0,0 +1,119 @@
from cmdClient.checks import in_guild
import data
from core import Lion
from data import tables
from utils import interactive # noqa
from .module import module
first_emoji = "🥇"
second_emoji = "🥈"
third_emoji = "🥉"
@module.cmd(
"top",
desc="View the Study Time leaderboard.",
group="Statistics",
aliases=('ttop', 'toptime', 'top100'),
help_aliases={'top100': "View the Study Time top 100."}
)
@in_guild()
async def cmd_top(ctx):
"""
Usage``:
{prefix}top
{prefix}top100
Description:
Display the study time leaderboard, or the top 100.
Use the paging reactions or send `p<n>` to switch pages (e.g. `p11` to switch to page 11).
"""
# Handle args
if ctx.args and not ctx.args == "100":
return await ctx.error_reply(
"**Usage:**`{prefix}top` or `{prefix}top100`.".format(prefix=ctx.best_prefix)
)
top100 = (ctx.args == "100" or ctx.alias == "top100")
# Fetch the leaderboard
exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members)
exclude.update(ctx.client.user_blacklist())
exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id])
args = {
'guildid': ctx.guild.id,
'select_columns': ('userid', 'total_tracked_time::INTEGER'),
'_extra': "AND total_tracked_time > 0 ORDER BY total_tracked_time DESC " + ("LIMIT 100" if top100 else "")
}
if exclude:
args['userid'] = data.NOT(list(exclude))
user_data = tables.members_totals.select_where(**args)
# Quit early if the leaderboard is empty
if not user_data:
return await ctx.reply("No leaderboard entries yet!")
# Extract entries
author_index = None
entries = []
for i, (userid, time) in enumerate(user_data):
member = ctx.guild.get_member(userid)
name = member.display_name if member else str(userid)
name = name.replace('*', ' ').replace('_', ' ')
num_str = "{}.".format(i+1)
hours = time // 3600
minutes = time // 60 % 60
seconds = time % 60
time_str = "{}:{:02}:{:02}".format(
hours,
minutes,
seconds
)
if ctx.author.id == userid:
author_index = i
entries.append((num_str, name, time_str))
# Extract blocks
blocks = [entries[i:i+20] for i in range(0, len(entries), 20)]
block_count = len(blocks)
# Build strings
header = "Study Time Top 100" if top100 else "Study Time Leaderboard"
if block_count > 1:
header += " (Page {{page}}/{})".format(block_count)
# Build pages
pages = []
for i, block in enumerate(blocks):
max_num_l, max_name_l, max_time_l = [max(len(e[i]) for e in block) for i in (0, 1, 2)]
body = '\n'.join(
"{:>{}} {:<{}} \t {:>{}} {} {}".format(
entry[0], max_num_l,
entry[1], max_name_l + 2,
entry[2], max_time_l + 1,
first_emoji if i == 0 and j == 0 else (
second_emoji if i == 0 and j == 1 else (
third_emoji if i == 0 and j == 2 else ''
)
),
"" if author_index is not None and author_index == i * 20 + j else ""
)
for j, entry in enumerate(block)
)
title = header.format(page=i+1)
line = '='*len(title)
pages.append(
"```md\n{}\n{}\n{}```".format(title, line, body)
)
# Finally, page the results
await ctx.pager(pages, start_at=(author_index or 0)//20 if not top100 else 0)

View File

@@ -0,0 +1,5 @@
from .module import module
from . import badges
from . import timers
from . import tracking

View File

@@ -0,0 +1,2 @@
from . import badge_tracker
from . import studybadge_cmd

View File

@@ -0,0 +1,349 @@
import datetime
import traceback
import logging
import asyncio
import contextlib
import discord
from meta import client, sharding
from data.conditions import GEQ, THIS_SHARD
from core.data import lions
from utils.lib import strfdur
from settings import GuildSettings
from ..module import module
from .data import new_study_badges, study_badges
guild_locks = {} # guildid -> Lock
@contextlib.asynccontextmanager
async def guild_lock(guildid):
"""
Per-guild lock held while the study badges are being updated.
This should not be used to lock the data modifications, as those are synchronous.
Primarily for reporting and so that the member information (e.g. roles) stays consistent
through reading and manipulation.
"""
# Create the lock if it hasn't been registered already
if guildid in guild_locks:
lock = guild_locks[guildid]
else:
lock = guild_locks[guildid] = asyncio.Lock()
await lock.acquire()
try:
yield lock
finally:
lock.release()
async def update_study_badges(full=False):
while not client.is_ready():
await asyncio.sleep(1)
client.log(
"Running global study badge update.".format(
),
context="STUDY_BADGE_UPDATE",
level=logging.DEBUG
)
# TODO: Consider db procedure for doing the update and returning rows
# Retrieve member rows with out of date study badges
if not full and client.appdata.last_study_badge_scan is not None:
# TODO: _extra here is a hack to cover for inflexible conditionals
update_rows = new_study_badges.select_where(
guildid=THIS_SHARD,
_timestamp=GEQ(client.appdata.last_study_badge_scan or 0),
_extra="OR session_start IS NOT NULL AND (guildid >> 22) %% {} = {}".format(
sharding.shard_count, sharding.shard_number
)
)
else:
update_rows = new_study_badges.select_where(guildid=THIS_SHARD)
if not update_rows:
client.appdata.last_study_badge_scan = datetime.datetime.utcnow()
return
# Batch and fire guild updates
current_guildid = None
current_guild = None
guild_buffer = []
updated_guilds = set()
for row in update_rows:
if row['guildid'] != current_guildid:
if current_guild:
# Fire guild updater
asyncio.create_task(_update_guild_badges(current_guild, guild_buffer))
updated_guilds.add(current_guild.id)
guild_buffer = []
current_guildid = row['guildid']
current_guild = client.get_guild(row['guildid'])
if current_guild:
guild_buffer.append(row)
if current_guild:
# Fire guild updater
asyncio.create_task(_update_guild_badges(current_guild, guild_buffer))
updated_guilds.add(current_guild.id)
# Update the member study badges in data
lions.update_many(
*((row['current_study_badgeid'], row['guildid'], row['userid'])
for row in update_rows if row['guildid'] in updated_guilds),
set_keys=('last_study_badgeid',),
where_keys=('guildid', 'userid'),
cast_row='(NULL::int, NULL::int, NULL::int)'
)
# Update the app scan time
client.appdata.last_study_badge_scan = datetime.datetime.utcnow()
async def _update_guild_badges(guild, member_rows, notify=True, log=True):
"""
Notify, update, and log role changes for a single guild.
Expects a valid `guild` and a list of Rows of `new_study_badges`.
"""
async with guild_lock(guild.id):
client.log(
"Running guild badge update for guild '{guild.name}' (gid:{guild.id}) "
"with `{count}` rows to update.".format(
guild=guild,
count=len(member_rows)
),
context="STUDY_BADGE_UPDATE",
level=logging.DEBUG,
post=False
)
# Set of study role ids in this guild, usually from cache
guild_roles = {
roleid: guild.get_role(roleid)
for roleid in study_badges.queries.for_guild(guild.id)
}
log_lines = []
flags_used = set()
tasks = []
for row in member_rows:
# Fetch member
# TODO: Potential verification issue
member = guild.get_member(row['userid'])
if member:
tasks.append(
asyncio.create_task(
_update_member_roles(row, member, guild_roles, log_lines, flags_used, notify)
)
)
# Post to the event log, in multiple pages if required
event_log = GuildSettings(guild.id).event_log.value
if tasks:
task_blocks = (tasks[i:i+20] for i in range(0, len(tasks), 20))
for task_block in task_blocks:
# Execute the tasks
await asyncio.gather(*task_block)
# Post to the log if needed
if log and event_log:
desc = "\n".join(log_lines)
embed = discord.Embed(
title="Study badge{} earned!".format('s' if len(log_lines) > 1 else ''),
description=desc,
colour=discord.Colour.orange(),
timestamp=datetime.datetime.utcnow()
)
if flags_used:
flag_desc = {
'!': "`!` Could not add/remove badge role. **Check permissions!**",
'*': "`*` Could not message member.",
'x': "`x` Couldn't find role to add/remove!"
}
flag_lines = '\n'.join(desc for flag, desc in flag_desc.items() if flag in flags_used)
embed.add_field(
name="Legend",
value=flag_lines
)
try:
await event_log.send(embed=embed)
except discord.HTTPException:
# Nothing we can really do
pass
# Flush the log collection pointers
log_lines.clear()
flags_used.clear()
# Wait so we don't get ratelimited
await asyncio.sleep(0.5)
# Debug log completion
client.log(
"Completed guild badge update for guild '{guild.name}' (gid:{guild.id})".format(
guild=guild,
),
context="STUDY_BADGE_UPDATE",
level=logging.DEBUG,
post=False
)
async def _update_member_roles(row, member, guild_roles, log_lines, flags_used, notify):
guild = member.guild
# Logging flag chars
flags = []
# Add new study role
# First fetch the roleid using the current_study_badgeid
new_row = study_badges.fetch(row['current_study_badgeid']) if row['current_study_badgeid'] else None
# Fetch actual role from the precomputed guild roles
to_add = guild_roles.get(new_row.roleid, None) if new_row else None
if to_add:
# Actually add the role
try:
await member.add_roles(
to_add,
atomic=True,
reason="Updating study badge."
)
except discord.HTTPException:
flags.append('!')
elif new_row:
flags.append('x')
# Remove other roles, start by trying the last badge role
old_row = study_badges.fetch(row['last_study_badgeid']) if row['last_study_badgeid'] else None
member_roleids = set(role.id for role in member.roles)
if old_row and old_row.roleid in member_roleids:
# The last level role exists, try to remove it
try:
await member.remove_roles(
guild_roles.get(old_row.roleid),
atomic=True
)
except discord.HTTPException:
# Couldn't remove the role
flags.append('!')
else:
# The last level role doesn't exist or the member doesn't have it
# Remove all leveled roles they have
current_roles = (
role for roleid, role in guild_roles.items()
if roleid in member_roleids and (not to_add or roleid != to_add.id)
)
if current_roles:
try:
await member.remove_roles(
*current_roles,
atomic=True,
reason="Updating study badge."
)
except discord.HTTPException:
# Couldn't remove one or more of the leveled roles
flags.append('!')
# Send notification to member
# TODO: Config customisation
if notify and new_row and (old_row is None or new_row.required_time > old_row.required_time):
req = new_row.required_time
if req < 3600:
timestr = "{} minutes".format(int(req // 60))
elif req == 3600:
timestr = "1 hour"
elif req % 3600:
timestr = "{:.1f} hours".format(req / 3600)
else:
timestr = "{} hours".format(int(req // 3600))
embed = discord.Embed(
title="New Study Badge!",
description="Congratulations! You have earned {} for studying **{}**!".format(
"**{}**".format(to_add.name) if to_add else "a new study badge!",
timestr
),
timestamp=datetime.datetime.utcnow(),
colour=discord.Colour.orange()
).set_footer(text=guild.name, icon_url=guild.icon_url)
try:
await member.send(embed=embed)
except discord.HTTPException:
flags.append('*')
# Add to event log message
if new_row:
new_role_str = "earned <@&{}> **({})**".format(new_row.roleid, strfdur(new_row.required_time))
else:
new_role_str = "lost their study badge!"
log_lines.append(
"<@{}> {} {}".format(
row['userid'],
new_role_str,
"`[{}]`".format(''.join(flags)) if flags else "",
)
)
if flags:
flags_used.update(flags)
async def study_badge_tracker():
"""
Runloop for the study badge updater.
"""
while True:
try:
await update_study_badges()
except Exception:
# Unknown exception. Catch it so the loop doesn't die.
client.log(
"Error while updating study badges! "
"Exception traceback follows.\n{}".format(
traceback.format_exc()
),
context="STUDY_BADGE_TRACKER",
level=logging.ERROR
)
# Long delay since this is primarily needed for external modifications
# or badge updates while studying
await asyncio.sleep(60)
async def update_member_studybadge(member):
"""
Checks and (if required) updates the study badge for a single member.
"""
update_rows = new_study_badges.select_where(
guildid=member.guild.id,
userid=member.id
)
if update_rows:
# Debug log the update
client.log(
"Updating study badge for user '{member.name}' (uid:{member.id}) "
"in guild '{member.guild.name}' (gid:{member.guild.id}).".format(
member=member
),
context="STUDY_BADGE_UPDATE",
level=logging.DEBUG
)
# Update the data first
lions.update_where({'last_study_badgeid': update_rows[0]['current_study_badgeid']},
guildid=member.guild.id, userid=member.id)
# Run the update task
await _update_guild_badges(member.guild, update_rows)
@module.launch_task
async def launch_study_badge_tracker(client):
asyncio.create_task(study_badge_tracker())

View File

@@ -0,0 +1,24 @@
from cachetools import cached
from data import Table, RowTable
study_badges = RowTable(
'study_badges',
('badgeid', 'guildid', 'roleid', 'required_time'),
'badgeid'
)
current_study_badges = Table('current_study_badges')
new_study_badges = Table('new_study_badges')
# Cache of study role ids attached to each guild. Not automatically updated.
guild_role_cache = {} # guildid -> set(roleids)
@study_badges.save_query
@cached(guild_role_cache)
def for_guild(guildid):
rows = study_badges.fetch_rows_where(guildid=guildid)
return set(row.roleid for row in rows)

View File

@@ -0,0 +1,462 @@
import re
import asyncio
import discord
import datetime
from cmdClient.checks import in_guild
from cmdClient.lib import SafeCancellation
from data import NULL
from utils.lib import parse_dur, strfdur, parse_ranges
from wards import is_guild_admin
from core.data import lions
from settings import GuildSettings
from ..module import module
from .data import study_badges, guild_role_cache, new_study_badges
from .badge_tracker import _update_guild_badges
_multiselect_regex = re.compile(
r"^([0-9, -]+)$",
re.DOTALL | re.IGNORECASE | re.VERBOSE
)
@module.cmd(
"studybadges",
group="Guild Configuration",
desc="View or configure the server study badges.",
aliases=('studyroles', 'studylevels'),
flags=('add', 'remove', 'clear', 'refresh')
)
@in_guild()
async def cmd_studybadges(ctx, flags):
"""
Usage``:
{prefix}studybadges
{prefix}studybadges [--add] <role>, <duration>
{prefix}studybadges --remove
{prefix}studybadges --remove <role>
{prefix}studybadges --remove <badge index>
{prefix}studybadges --clear
{prefix}studybadges --refresh
Description:
View or modify the study badges in this guild.
*Modification requires administrator permissions.*
Flags::
add: Add new studybadges (each line is added as a separate badge).
remove: Remove badges. With no arguments, opens a selection menu.
clear: Remove all study badges.
refresh: Make sure everyone's study badges are up to date.
Examples``:
{prefix}studybadges Lion Cub, 100h
{prefix}studybadges --remove Lion Cub
"""
if flags['refresh']:
await ensure_admin(ctx)
# Count members who need updating.
# Note that we don't get the rows here in order to avoid clashing with the auto-updater
update_count = new_study_badges.select_one_where(
guildid=ctx.guild.id,
select_columns=('COUNT(*)',)
)[0]
if not update_count:
# No-one needs updating
await ctx.reply("All study badges are up to date!")
return
else:
out_msg = await ctx.reply("Updating `{}` members (this may take a while)...".format(update_count))
# Fetch actual update rows
update_rows = new_study_badges.select_where(
guildid=ctx.guild.id
)
# Update data first
lions.update_many(
*((row['current_study_badgeid'], ctx.guild.id, row['userid']) for row in update_rows),
set_keys=('last_study_badgeid',),
where_keys=('guildid', 'userid')
)
# Then apply the role updates and send notifications as usual
await _update_guild_badges(ctx.guild, update_rows)
await out_msg.edit("Refresh complete! All study badges are up to date.")
elif flags['clear'] or flags['remove']:
# Make sure that the author is an admin before modifying the roles
await ensure_admin(ctx)
# Pre-fetch the list of roles
guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC")
if not guild_roles:
return await ctx.error_reply("There are no studybadges to remove!")
# Input handling, parse or get the list of rows to delete
to_delete = []
if flags['remove']:
if ctx.args:
if ctx.args.isdigit() and 0 < int(ctx.args) <= len(guild_roles):
# Assume it is a badge index
row = guild_roles[int(ctx.args) - 1]
else:
# Assume the input is a role string
# Get the collection of roles to search
roleids = (row.roleid for row in guild_roles)
roles = (ctx.guild.get_role(roleid) for roleid in roleids)
roles = [role for role in roles if role is not None]
role = await ctx.find_role(ctx.args, interactive=True, collection=roles, allow_notfound=False)
index = roles.index(role)
row = guild_roles[index]
# We now have a row to delete
to_delete = [row]
else:
# Multi-select the badges to remove
out_msg = await show_badge_list(
ctx,
desc="Please select the badge(s) to delete, or type `c` to cancel.",
guild_roles=guild_roles
)
def check(msg):
valid = msg.channel == ctx.ch and msg.author == ctx.author
valid = valid and (re.search(_multiselect_regex, msg.content) or msg.content.lower() == 'c')
return valid
try:
message = await ctx.client.wait_for('message', check=check, timeout=60)
except asyncio.TimeoutError:
await out_msg.delete()
await ctx.error_reply("Session timed out. No study badges were deleted.")
return
try:
await out_msg.delete()
await message.delete()
except discord.HTTPException:
pass
if message.content.lower() == 'c':
return
to_delete = [
guild_roles[index-1]
for index in parse_ranges(message.content) if index <= len(guild_roles)
]
elif flags['clear']:
if not await ctx.ask("Are you sure you want to delete **all** study badges in this server?"):
return
to_delete = guild_roles
# In some cases we may come out with no valid rows, in this case cancel.
if not to_delete:
return await ctx.error_reply("No matching badges, nothing to do!")
# Count the affected users
affected_count = lions.select_one_where(
guildid=ctx.guild.id,
last_study_badgeid=[row.badgeid for row in to_delete],
select_columns=('COUNT(*)',)
)[0]
# Delete the rows
study_badges.delete_where(badgeid=[row.badgeid for row in to_delete])
# Also update the cached guild roles
guild_role_cache.pop((ctx.guild.id, ), None)
study_badges.queries.for_guild(ctx.guild.id)
# Immediately refresh the member data, only for members with NULL badgeid
update_rows = new_study_badges.select_where(
guildid=ctx.guild.id,
last_study_badgeid=NULL
)
if update_rows:
lions.update_many(
*((row['current_study_badgeid'], ctx.guild.id, row['userid']) for row in update_rows),
set_keys=('last_study_badgeid',),
where_keys=('guildid', 'userid')
)
# Launch the update task for these members, so that they get the correct new roles
asyncio.create_task(_update_guild_badges(ctx.guild, update_rows, notify=False, log=False))
# Ack the deletion
count = len(to_delete)
roles = [ctx.guild.get_role(row.roleid) for row in to_delete]
if count == len(guild_roles):
await ctx.embed_reply("All study badges deleted.")
log_embed = discord.Embed(
title="Study badges cleared!",
description="{} cleared the guild study badges. `{}` members affected.".format(
ctx.author.mention,
affected_count
)
)
elif count == 1:
badge_name = roles[0].name if roles[0] else strfdur(to_delete[0].required_time)
await show_badge_list(
ctx,
desc="✅ Removed the **{}** badge.".format(badge_name)
)
log_embed = discord.Embed(
title="Study badge removed!",
description="{} removed the badge **{}**. `{}` members affected.".format(
ctx.author.mention,
badge_name,
affected_count
)
)
else:
await show_badge_list(
ctx,
desc="✅ `{}` badges removed.".format(count)
)
log_embed = discord.Embed(
title="Study badges removed!",
description="{} removed `{}` badges. `{}` members affected.".format(
ctx.author.mention,
count,
affected_count
)
)
# Post to the event log
event_log = GuildSettings(ctx.guild.id).event_log.value
if event_log:
# TODO Error handling? Or improve the post method?
log_embed.timestamp = datetime.datetime.utcnow()
log_embed.colour = discord.Colour.orange()
await event_log.send(embed=log_embed)
# Delete the roles (after asking first)
roles = [role for role in roles if role is not None]
if roles:
if await ctx.ask("Do you also want to remove the associated guild roles?"):
tasks = [
asyncio.create_task(role.delete()) for role in roles
]
results = await asyncio.gather(
*tasks,
return_exceptions=True
)
bad_roles = [role for role, task in zip(roles, tasks) if task.exception()]
if bad_roles:
await ctx.embed_reply(
"Couldn't delete the following roles:\n{}".format(
'\n'.join(bad_role.mention for bad_role in bad_roles)
)
)
else:
await ctx.embed_reply("Deleted `{}` roles.".format(len(roles)))
elif ctx.args:
# Ensure admin perms for modification
await ensure_admin(ctx)
guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC")
# Parse the input
lines = ctx.args.splitlines()
results = [await parse_level(ctx, line) for line in lines]
# Check for duplicates
_set = set()
duplicate = next((time for time, _ in results if time in _set or _set.add(time)), None)
if duplicate:
return await ctx.error_reply(
"Level `{}` provided twice!".format(strfdur(duplicate, short=False))
)
current_times = set(row.required_time for row in guild_roles)
# Split up the provided lines into levels to add and levels to edit
to_add = [result for result in results if result[0] not in current_times]
to_edit = [result for result in results if result[0] in current_times]
# Apply changes to database
if to_add:
study_badges.insert_many(
*((ctx.guild.id, time, role.id) for time, role in to_add),
insert_keys=('guildid', 'required_time', 'roleid')
)
if to_edit:
study_badges.update_many(
*((role.id, ctx.guild.id, time) for time, role in to_edit),
set_keys=('roleid',),
where_keys=('guildid', 'required_time')
)
# Also update the cached guild roles
guild_role_cache.pop((ctx.guild.id, ), None)
study_badges.queries.for_guild(ctx.guild.id)
# Ack changes
if to_add and to_edit:
desc = "{tick} `{num_add}` badges added and `{num_edit}` updated."
elif to_add:
desc = "{tick} `{num_add}` badges added."
elif to_edit:
desc = "{tick} `{num_edit}` badges updated."
desc = desc.format(
tick='',
num_add=len(to_add),
num_edit=len(to_edit)
)
await show_badge_list(ctx, desc)
# Count members who need new study badges
# Note that we don't get the rows here in order to avoid clashing with the auto-updater
update_count = new_study_badges.select_one_where(
guildid=ctx.guild.id,
select_columns=('COUNT(*)',)
)[0]
if not update_count:
# No-one needs updating
return
if update_count > 20:
# Confirm whether we want to update now
resp = await ctx.ask(
"`{}` members need their study badge roles updated, "
"which will occur automatically for each member when they next study.\n"
"Do you want to refresh the roles immediately instead? This may take a while!"
)
if not resp:
return
# Fetch actual update rows
update_rows = new_study_badges.select_where(
guildid=ctx.guild.id
)
# Update data first
lions.update_many(
*((row['current_study_badgeid'], ctx.guild.id, row['userid']) for row in update_rows),
set_keys=('last_study_badgeid',),
where_keys=('guildid', 'userid')
)
# Then apply the role updates and send notifications as usual
await _update_guild_badges(ctx.guild, update_rows)
# TODO: Progress bar? Probably not needed since we have the event log
# TODO: Ask about notifications?
else:
guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC")
# Just view the current study levels
if not guild_roles:
return await ctx.reply("There are no study badges set up!")
# TODO: You are at... this much to next level..
await show_badge_list(ctx, guild_roles=guild_roles)
async def parse_level(ctx, line):
line = line.strip()
if ',' in line:
splits = [split.strip() for split in line.split(',', maxsplit=1)]
elif line.startswith('"') and '"' in line[1:]:
splits = [split.strip() for split in line[1:].split('"', maxsplit=1)]
else:
splits = [split.strip() for split in line.split(maxsplit=1)]
if not line or len(splits) != 2 or not splits[1][0].isdigit():
raise SafeCancellation(
"**Level Syntax:** `<role>, <required_time>`, for example `Lion Cub, 200h`."
)
if splits[1].isdigit():
# No units! Assume hours
time = int(splits[1]) * 3600
else:
time = parse_dur(splits[1])
role_str = splits[0]
# TODO maybe add Y.. yes to all
role = await ctx.find_role(role_str, create=True, interactive=True, allow_notfound=False)
return time, role
async def ensure_admin(ctx):
if not is_guild_admin(ctx.author):
raise SafeCancellation("Only guild admins can modify the server study badges!")
async def show_badge_list(ctx, desc=None, guild_roles=None):
if guild_roles is None:
guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC")
# Generate the time range strings
time_strings = []
first_time = guild_roles[0].required_time
if first_time == 0:
prev_time_str = '0'
prev_time_hour = False
else:
prev_time_str = strfdur(guild_roles[0].required_time)
prev_time_hour = not (guild_roles[0].required_time % 3600)
for row in guild_roles[1:]:
time = row.required_time
time_str = strfdur(time)
time_hour = not (time % 3600)
if time_hour and prev_time_hour:
time_strings.append(
"{} - {}".format(prev_time_str[:-1], time_str)
)
else:
time_strings.append(
"{} - {}".format(prev_time_str, time_str)
)
prev_time_str = time_str
prev_time_hour = time_hour
time_strings.append(
"{}".format(prev_time_str)
)
# Pair the time strings with their roles
pairs = [
(time_string, row.roleid)
for time_string, row in zip(time_strings, guild_roles)
]
# pairs = [
# (strfdur(row.required_time), row.study_role)
# for row in guild_roles
# ]
# Split the pairs into blocks
pair_blocks = [pairs[i:i+10] for i in range(0, len(pairs), 10)]
# Format the blocks into strings
blocks = []
for i, pair_block in enumerate(pair_blocks):
dig_len = (i * 10 + len(pair_block)) // 10 + 1
blocks.append('\n'.join(
"`[{:<{}}]` | <@&{}> **({})**".format(
i * 10 + j + 1,
dig_len,
role,
time_string,
) for j, (time_string, role) in enumerate(pair_block)
))
# Compile the strings into pages
pages = [
discord.Embed(
title="Study Badges in {}! \nStudy more to rank up!".format(ctx.guild.name),
description="{}\n\n{}".format(desc, block) if desc else block
) for block in blocks
]
# Output and page the pages
return await ctx.pager(pages)

View File

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

View File

@@ -0,0 +1,448 @@
import math
import asyncio
import discord
from collections import namedtuple
from datetime import timedelta
from utils.lib import utc_now
from utils.interactive import discord_shield
from meta import client
from settings import GuildSettings
from data.conditions import THIS_SHARD
from ..module import module
from .data import timers as timer_table
Stage = namedtuple('Stage', ['name', 'start', 'duration', 'end'])
class Timer:
timers = {} # channelid -> Timer
def __init__(self, channelid):
self.channelid = channelid
self.last_seen = {
} # Memberid -> timestamps
self.reaction_message = None
self._state = None
self._last_voice_update = None
self._voice_update_task = None
self._run_task = None
self._runloop_task = None
@classmethod
def create(cls, channel, focus_length, break_length, **kwargs):
timer_table.create_row(
channelid=channel.id,
guildid=channel.guild.id,
focus_length=focus_length,
break_length=break_length,
last_started=kwargs.pop('last_started', utc_now()),
**kwargs
)
return cls(channel.id)
@classmethod
def fetch_timer(cls, channelid):
return cls.timers.get(channelid, None)
@classmethod
def fetch_guild_timers(cls, guildid):
timers = []
guild = client.get_guild(guildid)
if guild:
for channel in guild.voice_channels:
if (timer := cls.timers.get(channel.id, None)):
timers.append(timer)
return timers
@property
def data(self):
return timer_table.fetch(self.channelid)
@property
def focus_length(self):
return self.data.focus_length
@property
def break_length(self):
return self.data.break_length
@property
def inactivity_threshold(self):
return self.data.inactivity_threshold or 3
@property
def current_stage(self):
if (last_start := self.data.last_started) is None:
# Timer hasn't been started
return None
now = utc_now()
diff = (now - last_start).total_seconds()
diff %= (self.focus_length + self.break_length)
if diff > self.focus_length:
return Stage(
'BREAK',
now - timedelta(seconds=(diff - self.focus_length)),
self.break_length,
now + timedelta(seconds=(- diff + self.focus_length + self.break_length))
)
else:
return Stage(
'FOCUS',
now - timedelta(seconds=diff),
self.focus_length,
now + timedelta(seconds=(self.focus_length - diff))
)
@property
def guild(self):
return client.get_guild(self.data.guildid)
@property
def channel(self):
return client.get_channel(self.channelid)
@property
def text_channel(self):
if (channelid := self.data.text_channelid) and (channel := self.guild.get_channel(channelid)):
return channel
else:
return GuildSettings(self.data.guildid).pomodoro_channel.value
@property
def members(self):
if (channel := self.channel):
return [member for member in channel.members if not member.bot]
else:
return []
@property
def channel_name(self):
"""
Current name for the voice channel
"""
stage = self.current_stage
name_format = self.data.channel_name or "{remaining} {stage} -- {name}"
name = name_format.replace(
'{remaining}', "{}m".format(
int(5 * math.ceil((stage.end - utc_now()).total_seconds() / 300)),
)
).replace(
'{stage}', stage.name.lower()
).replace(
'{members}', str(len(self.channel.members))
).replace(
'{name}', self.data.pretty_name or "WORK ROOM"
).replace(
'{pattern}',
"{}/{}".format(
int(self.focus_length // 60), int(self.break_length // 60)
)
)
return name[:100]
async def notify_change_stage(self, old_stage, new_stage):
# Update channel name
asyncio.create_task(self._update_channel_name())
# Kick people if they need kicking
to_warn = []
to_kick = []
warn_threshold = (self.inactivity_threshold - 1) * (self.break_length + self.focus_length)
kick_threshold = self.inactivity_threshold * (self.break_length + self.focus_length)
for member in self.members:
if member.id in self.last_seen:
diff = (utc_now() - self.last_seen[member.id]).total_seconds()
if diff >= kick_threshold:
to_kick.append(member)
elif diff > warn_threshold:
to_warn.append(member)
else:
# Shouldn't really happen, but
self.last_seen[member.id] = utc_now()
content = []
if to_kick:
# Do kick
await asyncio.gather(
*(member.edit(voice_channel=None) for member in to_kick),
return_exceptions=True
)
kick_string = (
"**Kicked due to inactivity:** {}".format(', '.join(member.mention for member in to_kick))
)
content.append(kick_string)
if to_warn:
warn_string = (
"**Please react to avoid being kicked:** {}".format(
', '.join(member.mention for member in to_warn)
)
)
content.append(warn_string)
# Send a new status/reaction message
if self.text_channel and self.members:
old_reaction_message = self.reaction_message
# Send status image, add reaction
args = await self.status()
if status_content := args.pop('content', None):
content.append(status_content)
self.reaction_message = await self.text_channel.send(
content='\n'.join(content),
**args
)
await self.reaction_message.add_reaction('')
if old_reaction_message:
asyncio.create_task(discord_shield(old_reaction_message.delete()))
# Ping people
members = self.members
blocks = [
''.join(member.mention for member in members[i:i+90])
for i in range(0, len(members), 90)
]
await asyncio.gather(
*(self.text_channel.send(block, delete_after=0.5) for block in blocks),
return_exceptions=True
)
elif not self.members:
await self.update_last_status()
# TODO: DM task if anyone has notifications on
# Mute or unmute everyone in the channel as needed
# Not possible, due to Discord restrictions
# overwrite = self.channel.overwrites_for(self.channel.guild.default_role)
# overwrite.speak = (new_stage.name == 'BREAK')
# try:
# await self.channel.set_permissions(
# self.channel.guild.default_role,
# overwrite=overwrite
# )
# except discord.HTTPException:
# pass
# Run the notify hook
await self.notify_hook(old_stage, new_stage)
async def notify_hook(self, old_stage, new_stage):
"""
May be overridden to provide custom actions during notification.
For example, for voice alerts.
"""
...
async def _update_channel_name(self):
# Attempt to update the voice channel name
# Ensures that only one update is pending at any time
# Attempts to wait until the next viable channel update
if self._voice_update_task:
self._voice_update_task.cancel()
if not self.channel:
return
if self.channel.name == self.channel_name:
return
if not self.channel.permissions_for(self.channel.guild.me).manage_channels:
return
if self._last_voice_update:
to_wait = ((self._last_voice_update + timedelta(minutes=5)) - utc_now()).total_seconds()
if to_wait > 0:
self._voice_update_task = asyncio.create_task(asyncio.sleep(to_wait))
try:
await self._voice_update_task
except asyncio.CancelledError:
return
self._voice_update_task = asyncio.create_task(
self.channel.edit(name=self.channel_name)
)
try:
await self._voice_update_task
self._last_voice_update = utc_now()
except asyncio.CancelledError:
return
async def status(self):
"""
Returns argument dictionary compatible with `discord.Channel.send`.
"""
# Generate status message
stage = self.current_stage
stage_str = "**{}** minutes focus with **{}** minutes break".format(
self.focus_length // 60,
self.break_length // 60
)
remaining = (stage.end - utc_now()).total_seconds()
memberstr = ', '.join(member.mention for member in self.members[:20])
if len(self.members) > 20:
memberstr += '...'
description = (
("{}: {}\n"
"Currently in `{}`, with `{:02}:{:02}` remaining.\n"
"{}").format(
self.channel.mention,
stage_str,
stage.name,
int(remaining // 3600),
int((remaining // 60) % 60),
memberstr
)
)
embed = discord.Embed(
colour=discord.Colour.orange(),
description=description
)
return {'embed': embed}
async def update_last_status(self):
"""
Update the last posted status message, if it exists.
"""
args = await self.status()
repost = True
if self.reaction_message:
try:
await self.reaction_message.edit(**args)
except discord.HTTPException:
pass
else:
repost = False
if repost and self.text_channel:
try:
self.reaction_message = await self.text_channel.send(**args)
await self.reaction_message.add_reaction('')
except discord.HTTPException:
pass
return
async def destroy(self):
"""
Remove the timer.
"""
# Remove timer from cache
self.timers.pop(self.channelid, None)
# Cancel the loop
if self._run_task:
self._run_task.cancel()
# Delete the reaction message
if self.reaction_message:
try:
await self.reaction_message.delete()
except discord.HTTPException:
pass
# Remove the timer from data
timer_table.delete_where(channelid=self.channelid)
async def run(self):
"""
Runloop
"""
timer = self.timers.pop(self.channelid, None)
if timer and timer._run_task:
timer._run_task.cancel()
self.timers[self.channelid] = self
if not self.data.last_started:
self.data.last_started = utc_now()
asyncio.create_task(self.notify_change_stage(None, self.current_stage))
while True:
stage = self._state = self.current_stage
to_next_stage = (stage.end - utc_now()).total_seconds()
# Allow updating with 10 seconds of drift to stage change
if to_next_stage > 10 * 60 - 10:
time_to_sleep = 5 * 60
else:
time_to_sleep = to_next_stage
self._run_task = asyncio.create_task(asyncio.sleep(time_to_sleep))
try:
await self._run_task
except asyncio.CancelledError:
break
# Destroy the timer if our voice channel no longer exists
if not self.channel:
await self.destroy()
break
if self._state.end < utc_now():
asyncio.create_task(self.notify_change_stage(self._state, self.current_stage))
elif self.members:
asyncio.create_task(self._update_channel_name())
asyncio.create_task(self.update_last_status())
def runloop(self):
self._runloop_task = asyncio.create_task(self.run())
# Loading logic
@module.launch_task
async def load_timers(client):
timer_rows = timer_table.fetch_rows_where(
guildid=THIS_SHARD
)
count = 0
for row in timer_rows:
if client.get_channel(row.channelid):
# Channel exists
# Create the timer
timer = Timer(row.channelid)
# Populate the members
timer.last_seen = {
member.id: utc_now()
for member in timer.members
}
# Start the timer
timer.runloop()
count += 1
client.log(
"Loaded and start '{}' timers!".format(count),
context="TIMERS"
)
# Hooks
@client.add_after_event('raw_reaction_add')
async def reaction_tracker(client, payload):
if payload.guild_id and payload.member and not payload.member.bot and payload.member.voice:
if (channel := payload.member.voice.channel) and (timer := Timer.fetch_timer(channel.id)):
if timer.reaction_message and payload.message_id == timer.reaction_message.id:
timer.last_seen[payload.member.id] = utc_now()
@client.add_after_event('voice_state_update')
async def touch_member(client, member, before, after):
if not member.bot and after.channel != before.channel:
if after.channel and (timer := Timer.fetch_timer(after.channel.id)):
timer.last_seen[member.id] = utc_now()
await timer.update_last_status()
if before.channel and (timer := Timer.fetch_timer(before.channel.id)):
timer.last_seen.pop(member.id, None)
await timer.update_last_status()

View File

@@ -0,0 +1,3 @@
from .Timer import Timer
from . import commands
from . import settings

View File

@@ -0,0 +1,460 @@
import asyncio
import discord
from cmdClient.checks import in_guild
from cmdClient.lib import SafeCancellation
from LionContext import LionContext as Context
from wards import guild_admin
from utils.lib import utc_now, tick, prop_tabulate
from ..module import module
from .Timer import Timer
config_flags = ('name==', 'threshold=', 'channelname==', 'text==')
MAX_TIMERS_PER_GUILD = 10
options = {
"--name": "The timer name (as shown in alerts and `{prefix}timer`).",
"--channelname": "The name of the voice channel, see below for substitutions.",
"--threshold": "How many focus+break cycles before a member is kicked.",
"--text": "Text channel to send timer alerts in (defaults to value of `{prefix}config pomodoro_channel`)."
}
options_str = prop_tabulate(*zip(*options.items()))
@module.cmd(
"timer",
group="🆕 Pomodoro",
desc="View your study room timer.",
flags=config_flags,
aliases=('timers',)
)
@in_guild()
async def cmd_timer(ctx: Context, flags):
"""
Usage``:
{prefix}timer
{prefix}timers
Description:
Display your current study room timer status.
If you aren't in a study room, instead shows a list of timers you can join.
Use `{prefix}timers` to always show the list of timers instead.
"""
channel = ctx.author.voice.channel if ctx.author.voice and ctx.alias.lower() != 'timers' else None
if ctx.args:
if len(ctx.args.split()) > 1:
# Multiple arguments provided
# Assume configuration attempt
return await _pomo_admin(ctx, flags)
else:
# Single argument provided, assume channel reference
channel = await ctx.find_channel(
ctx.args,
interactive=True,
chan_type=discord.ChannelType.voice,
)
if channel is None:
return
if channel is None:
# Author is not in a voice channel, and they did not select a channel
# Display the server timers they can see
timers = Timer.fetch_guild_timers(ctx.guild.id)
timers = [
timer for timer in timers
if timer.channel and timer.channel.permissions_for(ctx.author).view_channel
]
if not timers:
if await guild_admin.run(ctx):
return await ctx.error_reply(
"No timers are running yet!\n"
f"Start a timer by joining a voice channel and running e.g. `{ctx.best_prefix}pomodoro 50, 10`.\n"
f"See `{ctx.best_prefix}help pomodoro for detailed usage."
)
else:
return await ctx.error_reply(
"No timers are running!\n"
f"You can ask an admin to start one using `{ctx.best_prefix}pomodoro`."
)
# Build a summary list
timer_strings = []
for timer in timers:
stage = timer.current_stage
stage_str = "(**`{}m`** focus, **`{}m`** break)".format(
int(timer.focus_length // 60), int(timer.break_length // 60)
)
if len(timer.members) > 1:
member_str = "**{}** members are ".format(len(timer.members))
elif len(timer.members) == 1:
member_str = "{} is ".format(timer.members[0].mention)
else:
member_str = ""
remaining = (stage.end - utc_now()).total_seconds()
timer_strings.append(
("{} {}\n"
"{}urrently **{}** with `{:02}:{:02}` left.").format(
timer.channel.mention,
stage_str,
member_str + 'c' if member_str else 'C',
"focusing" if stage.name == "FOCUS" else "resting",
int(remaining // 3600),
int((remaining // 60) % 60),
)
)
blocks = [
'\n\n'.join(timer_strings[i:i+10])
for i in range(0, len(timer_strings), 10)
]
embeds = [
discord.Embed(
title="Study Timers",
description=block,
colour=discord.Colour.orange()
)
for block in blocks
]
await ctx.pager(embeds)
else:
# We have a channel
# Get the associated timer
timer = Timer.fetch_timer(channel.id)
if timer is None:
# No timer in this channel
return await ctx.error_reply(
f"{channel.mention} doesn't have a timer running!"
)
else:
# We have a timer
# Show the timer status
await ctx.reply(**await timer.status())
@module.cmd(
"pomodoro",
group="Pomodoro",
desc="Add and configure timers for your study rooms.",
flags=config_flags
)
async def cmd_pomodoro(ctx, flags):
"""
Usage``:
{prefix}pomodoro [channelid] <focus time>, <break time> [channel name]
{prefix}pomodoro [channelid] [options]
{prefix}pomodoro [channelid] delete
Description:
Get started by joining a study voice channel and writing e.g. `{prefix}pomodoro 50, 10`.
The timer will start automatically and continue forever.
See the options and examples below for configuration.
Options::
--name: The timer name (as shown in alerts and `{prefix}timer`).
--channelname: The name of the voice channel, see below for substitutions.
--threshold: How many focus+break cycles before a member is kicked.
--text: Text channel to send timer alerts in (defaults to value of `{prefix}config pomodoro_channel`).
Channel name substitutions::
{{remaining}}: The time left in the current focus or break session, e.g. `10m`.
{{stage}}: The name of the current stage (`focus` or `break`).
{{name}}: The configured timer name.
{{pattern}}: The timer pattern in the form `focus/break` (e.g. `50/10`).
Examples:
Add a timer to your study room with `50` minutes focus, `10` minutes break.
> `{prefix}pomodoro 50, 10`
Add a timer with a custom updating channel name
> `{prefix}pomodoro 50, 10 {{remaining}} {{stage}} -- {{pattern}} room`
Change the name on the `{prefix}timer` status
> `{prefix}pomodoro --name 50/10 study room`
Change the updating channel name
> `{prefix}pomodoro --channelname {{remaining}} left -- {{name}}`
"""
await _pomo_admin(ctx, flags)
async def _pomo_admin(ctx, flags):
# Extract target channel
if ctx.author.voice:
channel = ctx.author.voice.channel
else:
channel = None
args = ctx.args
if ctx.args:
splits = ctx.args.split(maxsplit=1)
assume_channel = not (',' in splits[0])
assume_channel = assume_channel and not (channel and len(splits[0]) < 5)
assume_channel = assume_channel or (splits[0].strip('#<>').isdigit() and len(splits[0]) > 10)
if assume_channel:
# Assume first argument is a channel specifier
channel = await ctx.find_channel(
splits[0], interactive=True, chan_type=discord.ChannelType.voice
)
if not channel:
# Invalid channel provided
# find_channel already gave a message, just return silently
return
args = splits[1] if len(splits) > 1 else ""
if not args and not any(flags.values()):
# No arguments given to the `pomodoro` command.
# TODO: If we have a channel, replace this with timer setting information
return await ctx.error_reply(
f"See `{ctx.best_prefix}help pomodoro` for usage and examples."
)
if not channel:
return await ctx.error_reply(
f"No channel specified!\n"
"Please join a voice channel or pass the channel id as the first argument.\n"
f"See `{ctx.best_prefix}help pomodoro` for usage and examples."
)
# Now we have a channel and configuration arguments
# Next check the user has authority to modify the timer
if not await guild_admin.run(ctx):
# TODO: The channel is a room they own?
return await ctx.error_reply(
"You need to be a guild admin to set up the pomodoro timers!"
)
# Get the associated timer, if it exists
timer = Timer.fetch_timer(channel.id)
# Parse required action
if args.lower() == 'delete':
if timer:
await timer.destroy()
await ctx.embed_reply(
"Destroyed the timer in {}.".format(channel.mention)
)
else:
await ctx.error_reply(
"{} doesn't have a timer to delete!".format(channel.mention)
)
elif args or timer:
if args:
# Any provided arguments should be for setting up a new timer pattern
# Check the pomodoro channel exists
if not (timer and timer.text_channel) and not ctx.guild_settings.pomodoro_channel.value:
return await ctx.error_reply(
"Please set the pomodoro alerts channel first, "
f"with `{ctx.best_prefix}config pomodoro_channel <channel>`.\n"
f"For example: {ctx.best_prefix}config pomodoro_channel {ctx.ch.mention}"
)
# First validate input
try:
# Ensure no trailing commas
args = args.strip(',')
if ',' not in args:
raise SafeCancellation("Couldn't parse work and break times!")
timesplits = args.split(',', maxsplit=1)
if not timesplits[0].isdigit() or len(timesplits[0]) > 3:
raise SafeCancellation(f"Couldn't parse the provided work period length `{timesplits[0]}`.")
breaksplits = timesplits[1].split(maxsplit=1)
if not breaksplits[0].isdigit() or len(breaksplits[0]) > 3:
raise SafeCancellation(f"Couldn't parse the provided break period length `{breaksplits[0]}`.")
except SafeCancellation as e:
usage = discord.Embed(
title="Couldn't understand arguments!",
colour=discord.Colour.red()
)
usage.add_field(
name="Usage",
value=(
f"`{ctx.best_prefix}{ctx.alias} [channelid] <work time>, <break time> [channel name template]"
)
)
usage.add_field(
name="Examples",
value=(
f"`{ctx.best_prefix}{ctx.alias} 50, 10`\n"
f"`{ctx.best_prefix}{ctx.alias} {channel.id} 50, 10`\n"
f"`{ctx.best_prefix}{ctx.alias} {channel.id} 50, 10 {{remaining}} - {channel.name}`\n"
),
inline=False
)
usage.set_footer(
text=f"For detailed usage and examples see {ctx.best_prefix}help pomodoro"
)
if e.msg:
usage.description = e.msg
return await ctx.reply(embed=usage)
# Input validation complete, assign values
focus_length = int(timesplits[0])
break_length = int(breaksplits[0])
channelname = breaksplits[1].strip() if len(breaksplits) > 1 else None
# Check the stages aren't too short
if focus_length < 5:
return await ctx.error_reply("The focus duration must be at least 5 minutes!")
if break_length < 5:
return await ctx.error_reply("The break duration must be at least 5 minutes!")
# Create or update the timer
if not timer:
# Create timer
# First check number of timers
timers = Timer.fetch_guild_timers(ctx.guild.id)
if len(timers) >= MAX_TIMERS_PER_GUILD:
return await ctx.error_reply(
"Cannot create another timer!\n"
"This server already has the maximum of `{}` timers.".format(MAX_TIMERS_PER_GUILD)
)
# First check permissions
if not channel.permissions_for(ctx.guild.me).send_messages:
embed = discord.Embed(
title="Could not create timer!",
description=f"I do not have sufficient guild permissions to join {channel.mention}!",
colour=discord.Colour.red()
)
return await ctx.reply(embed=embed)
# Create timer
timer = Timer.create(
channel,
focus_length * 60,
break_length * 60,
channel_name=channelname or None,
pretty_name=channel.name
)
timer.last_seen = {
member.id: utc_now()
for member in timer.members
}
timer.runloop()
# Post a new status message
await timer.update_last_status()
else:
# Update timer and restart
stage = timer.current_stage
timer.last_seen = {
member.id: utc_now()
for member in timer.members
}
with timer.data.batch_update():
timer.data.focus_length = focus_length * 60
timer.data.break_length = break_length * 60
timer.data.last_started = utc_now()
if channelname:
timer.data.channel_name = channelname
await timer.notify_change_stage(stage, timer.current_stage)
timer.runloop()
# Ack timer creation
embed = discord.Embed(
colour=discord.Colour.orange(),
title="Timer Started!",
description=(
f"Started a timer in {channel.mention} with **{focus_length}** "
f"minutes focus and **{break_length}** minutes break."
)
)
embed.add_field(
name="Further configuration",
value=(
"Use `{prefix}{ctx.alias} --setting value` to configure your new timer.\n"
"*Replace `--setting` with one of the below settings, "
"please see `{prefix}help pomodoro` for examples.*\n"
f"{options_str.format(prefix=ctx.best_prefix)}"
).format(prefix=ctx.best_prefix, ctx=ctx, channel=channel)
)
await ctx.reply(embed=embed)
to_set = []
if flags['name']:
# Handle name update
to_set.append((
'pretty_name',
flags['name'],
f"The timer will now appear as `{flags['name']}` in the status."
))
if flags['threshold']:
# Handle threshold update
if not flags['threshold'].isdigit():
return await ctx.error_reply(
"The provided threshold must be a number!"
)
to_set.append((
'inactivity_threshold',
int(flags['threshold']),
"Members will be unsubscribed after being inactive for more than `{}` focus+break stages.".format(
flags['threshold']
)
))
if flags['channelname']:
# Handle channel name update
to_set.append((
'channel_name',
flags['channelname'],
f"The voice channel name template is now `{flags['channelname']}`."
))
if flags['text']:
# Handle text channel update
flag = flags['text']
if flag.lower() == 'none':
# Check if there is a default channel
channel = ctx.guild_settings.pomodoro_channel.value
if channel:
# Unset the channel to the default
msg = f"The custom text channel has been unset! (Alerts will be sent to {channel.mention})"
to_set.append((
'text_channelid',
None,
msg
))
# Remove the last reaction message and send a new one
timer.reaction_message = None
# Ensure this happens after the data update
asyncio.create_task(timer.update_last_status())
else:
return await ctx.error_reply(
"The text channel cannot be unset because there is no `pomodoro_channel` set up!\n"
f"See `{ctx.best_prefix}config pomodoro_channel` for setting a default pomodoro channel."
)
else:
# Attempt to parse the provided channel
channel = await ctx.find_channel(flag, interactive=True, chan_type=discord.ChannelType.text)
if channel:
if not channel.permissions_for(ctx.guild.me).send_messages:
return await ctx.error_reply(
f"Cannot send pomodoro alerts to {channel.mention}! "
"I don't have permission to send messages there."
)
to_set.append((
'text_channelid',
channel.id,
f"Timer alerts and updates will now be sent to {channel.mention}."
))
# Remove the last reaction message and send a new one
timer.reaction_message = None
# Ensure this happens after the data update
asyncio.create_task(timer.update_last_status())
else:
# Ack has already been sent, just ignore
return
if to_set:
to_update = {item[0]: item[1] for item in to_set}
timer.data.update(**to_update)
desc = '\n'.join(f"{tick} {item[2]}" for item in to_set)
embed = discord.Embed(
title=f"Timer option{'s' if len(to_update) > 1 else ''} updated!",
description=desc,
colour=discord.Colour.green()
)
await ctx.reply(embed=embed)
else:
# Flags were provided, but there is no timer, and no timer was created
await ctx.error_reply(
f"No timer exists in {channel.mention} to set up!\n"
f"Create one with, for example, ```{ctx.best_prefix}pomodoro {channel.id} 50, 10```"
f"See `{ctx.best_prefix}help pomodoro` for more examples and usage."
)

View File

@@ -0,0 +1,15 @@
from data import RowTable
timers = RowTable(
'timers',
('channelid', 'guildid',
'text_channelid',
'focus_length', 'break_length',
'inactivity_threshold',
'last_started',
'text_channelid',
'channel_name', 'pretty_name'),
'channelid',
cache={}
)

View File

@@ -0,0 +1,47 @@
import asyncio
from settings import GuildSettings, GuildSetting
import settings
from . import Timer
@GuildSettings.attach_setting
class pomodoro_channel(settings.TextChannel, GuildSetting):
category = "Study Tracking"
attr_name = "pomodoro_channel"
_data_column = "pomodoro_channel"
display_name = "pomodoro_channel"
desc = "Channel to send pomodoro timer status updates and alerts."
_default = None
long_desc = (
"Channel to send pomodoro status updates to.\n"
"Members studying in rooms with an attached timer will need to be able to see "
"this channel to get notifications and react to the status messages."
)
_accepts = "Any text channel I can write to, or `None` to unset."
@property
def success_response(self):
timers = Timer.fetch_guild_timers(self.id)
if self.value:
for timer in timers:
if timer.reaction_message and timer.reaction_message.channel != self.value:
timer.reaction_message = None
asyncio.create_task(timer.update_last_status())
return f"The pomodoro alerts and updates will now be sent to {self.value.mention}"
else:
deleted = 0
for timer in timers:
if not timer.text_channel:
deleted += 1
asyncio.create_task(timer.destroy())
msg = "The pomodoro alert channel has been unset."
if deleted:
msg += f" `{deleted}` timers were subsequently deactivated."
return msg

View File

@@ -0,0 +1,4 @@
from . import data
from . import settings
from . import session_tracker
from . import commands

View File

@@ -0,0 +1,167 @@
from cmdClient.checks import in_guild
from LionContext import LionContext as Context
from core import Lion
from wards import is_guild_admin
from ..module import module
MAX_TAG_LENGTH = 10
@module.cmd(
"now",
group="🆕 Pomodoro",
desc="What are you working on?",
aliases=('studying', 'workingon'),
flags=('clear', 'new')
)
@in_guild()
async def cmd_now(ctx: Context, flags):
"""
Usage``:
{prefix}now [tag]
{prefix}now @mention
{prefix}now --clear
Description:
Describe the subject or goal you are working on this session with, for example, `{prefix}now Maths`.
Mention someone else to view what they are working on!
Flags::
clear: Remove your current tag.
Examples:
> {prefix}now Biology
> {prefix}now {ctx.author.mention}
"""
if flags['clear']:
if ctx.msg.mentions and is_guild_admin(ctx.author):
# Assume an admin is trying to clear another user's tag
for target in ctx.msg.mentions:
lion = Lion.fetch(ctx.guild.id, target.id)
if lion.session:
lion.session.data.tag = None
if len(ctx.msg.mentions) == 1:
await ctx.embed_reply(
f"Cleared session tags for {ctx.msg.mentions[0].mention}."
)
else:
await ctx.embed_reply(
f"Cleared session tags for:\n{', '.join(target.mention for target in ctx.msg.mentions)}."
)
else:
# Assume the user is clearing their own session tag
if (session := ctx.alion.session):
session.data.tag = None
await ctx.embed_reply(
"Removed your session study tag!"
)
else:
await ctx.embed_reply(
"You aren't studying right now, so there is nothing to clear!"
)
elif ctx.args:
if ctx.msg.mentions:
# Assume peeking at user's current session
# Smoll easter egg
target = ctx.msg.mentions[0]
if target == ctx.guild.me:
student_count, guild_count = ctx.client.data.current_sessions.select_one_where(
select_columns=("COUNT(*) AS studying_count", "COUNT(DISTINCT(guildid)) AS guild_count"),
)
if ctx.alion.session:
if (tag := ctx.alion.session.data.tag):
tail = f"Good luck with your **{tag}**!"
else:
tail = "Good luck with your study, I believe in you!"
else:
tail = "Do you want to join? Hop in a study channel and let's get to work!"
return await ctx.embed_reply(
"Thanks for asking!\n"
f"I'm just helping out the **{student_count}** "
f"dedicated people currently working across **{guild_count}** fun communities!\n"
f"{tail}"
)
lion = Lion.fetch(ctx.guild.id, target.id)
if not lion.session:
await ctx.embed_reply(
f"{target.mention} isn't working right now!"
)
else:
duration = lion.session.duration
if duration > 3600:
dur_str = "{}h {}m".format(
int(duration // 3600),
int((duration % 3600) // 60)
)
else:
dur_str = "{} minutes".format(int((duration % 3600) // 60))
if not lion.session.data.tag:
await ctx.embed_reply(
f"{target.mention} has been working in <#{lion.session.data.channelid}> for **{dur_str}**!"
)
else:
await ctx.embed_reply(
f"{target.mention} has been working on **{lion.session.data.tag}**"
f" in <#{lion.session.data.channelid}> for **{dur_str}**!"
)
else:
# Assume setting tag
tag = ctx.args
if not (session := ctx.alion.session):
return await ctx.error_reply(
"You aren't working right now! Join a study channel and try again!"
)
if len(tag) > MAX_TAG_LENGTH:
return await ctx.error_reply(
f"Please keep your tag under `{MAX_TAG_LENGTH}` characters long!"
)
old_tag = session.data.tag
session.data.tag = tag
if old_tag:
await ctx.embed_reply(
f"You have updated your session study tag. Good luck with **{tag}**!"
)
else:
await ctx.embed_reply(
"You have set your session study tag!\nIt will be reset when you leave, or join another channel.\n"
f"Good luck with **{tag}**!"
)
else:
# View current session, stats, and guide.
if (session := ctx.alion.session):
duration = session.duration
if duration > 3600:
dur_str = "{}h {}m".format(
int(duration // 3600),
int((duration % 3600) // 60)
)
else:
dur_str = "{} minutes".format(int((duration % 3600) / 60))
if not session.data.tag:
await ctx.embed_reply(
f"You have been working in <#{session.data.channelid}> for **{dur_str}**!\n"
f"Describe what you are working on with "
f"`{ctx.best_prefix}now <tag>`, e.g. `{ctx.best_prefix}now Maths`"
)
else:
await ctx.embed_reply(
f"You have been working on **{session.data.tag}**"
f" in <#{session.data.channelid}> for **{dur_str}**!"
)
else:
await ctx.embed_reply(
f"Join a study channel and describe what you are working on with e.g. `{ctx.best_prefix}now Maths`"
)
# TODO: Favourite tags listing
# Get tag history ranking top 5
# If there are any, display top 5
# Otherwise do nothing
...

View File

@@ -0,0 +1,86 @@
from psycopg2.extras import execute_values
from data import Table, RowTable, tables
from utils.lib import FieldEnum
untracked_channels = Table('untracked_channels')
class SessionChannelType(FieldEnum):
"""
The possible session channel types.
"""
# NOTE: "None" stands for Unknown, and the STANDARD description should be replaced with the channel name
STANDARD = 'STANDARD', "Standard"
ACCOUNTABILITY = 'ACCOUNTABILITY', "Accountability Room"
RENTED = 'RENTED', "Private Room"
EXTERNAL = 'EXTERNAL', "Unknown"
session_history = Table('session_history')
current_sessions = RowTable(
'current_sessions',
('guildid', 'userid', 'channelid', 'channel_type',
'rating', 'tag',
'start_time',
'live_duration', 'live_start',
'stream_duration', 'stream_start',
'video_duration', 'video_start',
'hourly_coins', 'hourly_live_coins'),
('guildid', 'userid'),
cache={} # Keep all current sessions in cache
)
@current_sessions.save_query
def close_study_session(guildid, userid):
"""
Close a member's current session if it exists and update the member cache.
"""
# Execute the `close_study_session` database function
with current_sessions.conn as conn:
cursor = conn.cursor()
cursor.callproc('close_study_session', (guildid, userid))
rows = cursor.fetchall()
# The row has been deleted, remove the from current sessions cache
current_sessions.row_cache.pop((guildid, userid), None)
# Use the function output to update the member cache
tables.lions._make_rows(*rows)
@session_history.save_query
def study_time_since(guildid, userid, timestamp):
"""
Retrieve the total member study time (in seconds) since the given timestamp.
Includes the current session, if it exists.
"""
with session_history.conn as conn:
cursor = conn.cursor()
cursor.callproc('study_time_since', (guildid, userid, timestamp))
rows = cursor.fetchall()
return (rows[0][0] if rows else None) or 0
@session_history.save_query
def study_times_since(guildid, userid, *timestamps):
"""
Retrieve the total member study time (in seconds) since the given timestamps.
Includes the current session, if it exists.
"""
with session_history.conn as conn:
cursor = conn.cursor()
data = execute_values(
cursor,
"""
SELECT study_time_since(t.guildid, t.userid, t.timestamp)
FROM (VALUES %s)
AS t (guildid, userid, timestamp)
""",
[(guildid, userid, timestamp) for timestamp in timestamps],
fetch=True
)
return data
members_totals = Table('members_totals')

View File

@@ -0,0 +1,504 @@
import asyncio
import discord
import logging
import traceback
from typing import Dict
from collections import defaultdict
from utils.lib import utc_now
from data import tables
from data.conditions import THIS_SHARD
from core import Lion
from meta import client
from ..module import module
from .data import current_sessions, SessionChannelType
from .settings import untracked_channels, hourly_reward, hourly_live_bonus
class Session:
"""
A `Session` describes an ongoing study session by a single guild member.
A member is counted as studying when they are in a tracked voice channel.
This class acts as an opaque interface to the corresponding `sessions` data row.
"""
__slots__ = (
'guildid',
'userid',
'_expiry_task'
)
# Global cache of ongoing sessions
sessions: Dict[int, Dict[int, 'Session']] = defaultdict(dict)
# Global cache of members pending session start (waiting for daily cap reset)
members_pending: Dict[int, Dict[int, asyncio.Task]] = defaultdict(dict)
def __init__(self, guildid, userid):
self.guildid = guildid
self.userid = userid
self._expiry_task: asyncio.Task = None
@classmethod
def get(cls, guildid, userid):
"""
Fetch the current session for the provided member.
If there is no current session, returns `None`.
"""
return cls.sessions[guildid].get(userid, None)
@classmethod
def start(cls, member: discord.Member, state: discord.VoiceState):
"""
Start a new study session for the provided member.
"""
guildid = member.guild.id
userid = member.id
now = utc_now()
if userid in cls.sessions[guildid]:
raise ValueError("A session for this member already exists!")
# If the user is study capped, schedule the session start for the next day
if (lion := Lion.fetch(guildid, userid)).remaining_study_today <= 10:
if pending := cls.members_pending[guildid].pop(userid, None):
pending.cancel()
task = asyncio.create_task(cls._delayed_start(guildid, userid, member, state))
cls.members_pending[guildid][userid] = task
client.log(
"Member (uid:{}) in (gid:{}) is study capped, "
"delaying session start for {} seconds until start of next day.".format(
userid, guildid, lion.remaining_in_day
),
context="SESSION_TRACKER",
level=logging.DEBUG
)
return
# TODO: More reliable channel type determination
if state.channel.id in tables.rented.row_cache:
channel_type = SessionChannelType.RENTED
elif state.channel.category and state.channel.category.id == lion.guild_settings.accountability_category.data:
channel_type = SessionChannelType.ACCOUNTABILITY
else:
channel_type = SessionChannelType.STANDARD
current_sessions.create_row(
guildid=guildid,
userid=userid,
channelid=state.channel.id,
channel_type=channel_type,
start_time=now,
live_start=now if (state.self_video or state.self_stream) else None,
stream_start=now if state.self_stream else None,
video_start=now if state.self_video else None,
hourly_coins=hourly_reward.get(guildid).value,
hourly_live_coins=hourly_live_bonus.get(guildid).value
)
session = cls(guildid, userid).activate()
client.log(
"Started session: {}".format(session.data),
context="SESSION_TRACKER",
level=logging.DEBUG,
)
@classmethod
async def _delayed_start(cls, guildid, userid, *args):
delay = Lion.fetch(guildid, userid).remaining_in_day
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
pass
else:
cls.start(*args)
@property
def key(self):
"""
RowTable Session identification key.
"""
return (self.guildid, self.userid)
@property
def lion(self):
"""
The Lion member object associated with this member.
"""
return Lion.fetch(self.guildid, self.userid)
@property
def data(self):
"""
Row of the `current_sessions` table corresponding to this session.
"""
return current_sessions.fetch(self.key)
@property
def duration(self):
"""
Current duration of the session.
"""
return (utc_now() - self.data.start_time).total_seconds()
@property
def coins_earned(self):
"""
Number of coins earned so far.
"""
data = self.data
coins = self.duration * data.hourly_coins
coins += data.live_duration * data.hourly_live_coins
if data.live_start:
coins += (utc_now() - data.live_start).total_seconds() * data.hourly_live_coins
return coins // 3600
def activate(self):
"""
Activate the study session.
This adds the session to the studying members cache,
and schedules the session expiry, based on the daily study cap.
"""
# Add to the active cache
self.sessions[self.guildid][self.userid] = self
# Schedule the session expiry
self.schedule_expiry()
# Return self for easy chaining
return self
def schedule_expiry(self):
"""
Schedule session termination when the user reaches the maximum daily study time.
"""
asyncio.create_task(self._schedule_expiry())
async def _schedule_expiry(self):
# Cancel any existing expiry
if self._expiry_task and not self._expiry_task.done():
self._expiry_task.cancel()
# Wait for the maximum session length
self._expiry_task = asyncio.create_task(asyncio.sleep(self.lion.remaining_study_today))
try:
await self._expiry_task
except asyncio.CancelledError:
pass
else:
if self.lion.remaining_study_today <= 10:
# End the session
# Note that the user will not automatically start a new session when the day starts
# TODO: Notify user? Disconnect them?
client.log(
"Session for (uid:{}) in (gid:{}) reached daily guild study cap.\n{}".format(
self.userid, self.guildid, self.data
),
context="SESSION_TRACKER"
)
self.finish()
else:
# It's possible the expiry time was pushed forwards while waiting
# If so, reschedule
self.schedule_expiry()
def finish(self):
"""
Close the study session.
"""
# Note that save_live_status doesn't need to be called here
# The database saving procedure will account for the values.
current_sessions.queries.close_study_session(*self.key)
# Remove session from active cache
self.sessions[self.guildid].pop(self.userid, None)
# Cancel any existing expiry task
if self._expiry_task and not self._expiry_task.done():
self._expiry_task.cancel()
def save_live_status(self, state: discord.VoiceState):
"""
Update the saved live status of the member.
"""
has_video = state.self_video
has_stream = state.self_stream
is_live = has_video or has_stream
now = utc_now()
data = self.data
with data.batch_update():
# Update video session stats
if data.video_start:
data.video_duration += (now - data.video_start).total_seconds()
data.video_start = now if has_video else None
# Update stream session stats
if data.stream_start:
data.stream_duration += (now - data.stream_start).total_seconds()
data.stream_start = now if has_stream else None
# Update overall live session stats
if data.live_start:
data.live_duration += (now - data.live_start).total_seconds()
data.live_start = now if is_live else None
async def session_voice_tracker(client, member, before, after):
"""
Voice update event dispatcher for study session tracking.
"""
if member.bot:
return
guild = member.guild
Lion.fetch(guild.id, member.id).update_saved_data(member)
session = Session.get(guild.id, member.id)
if before.channel == after.channel:
# Voice state change without moving channel
if session and ((before.self_video != after.self_video) or (before.self_stream != after.self_stream)):
# Live status has changed!
session.save_live_status(after)
else:
# Member changed channel
# End the current session and start a new one, if applicable
if session:
if (scid := session.data.channelid) and (not before.channel or scid != before.channel.id):
client.log(
"The previous voice state for "
"member {member.name} (uid:{member.id}) in {guild.name} (gid:{guild.id}) "
"does not match their current study session!\n"
"Session channel is (cid:{scid}), but the previous channel is {previous}.".format(
member=member,
guild=member.guild,
scid=scid,
previous="{0.name} (cid:{0.id})".format(before.channel) if before.channel else "None"
),
context="SESSION_TRACKER",
level=logging.ERROR
)
client.log(
"Ending study session for {member.name} (uid:{member.id}) "
"in {member.guild.id} (gid:{member.guild.id}) since they left the voice channel.\n{session}".format(
member=member,
session=session.data
),
context="SESSION_TRACKER",
post=False
)
# End the current session
session.finish()
elif pending := Session.members_pending[guild.id].pop(member.id, None):
client.log(
"Cancelling pending study session for {member.name} (uid:{member.id}) "
"in {member.guild.name} (gid:{member.guild.id}) since they left the voice channel.".format(
member=member
),
context="SESSION_TRACKER",
post=False
)
pending.cancel()
if after.channel:
blacklist = client.user_blacklist()
guild_blacklist = client.objects['ignored_members'][guild.id]
untracked = untracked_channels.get(guild.id).data
start_session = (
(after.channel.id not in untracked)
and (member.id not in blacklist)
and (member.id not in guild_blacklist)
)
if start_session:
# Start a new session for the member
client.log(
"Starting a new voice channel study session for {member.name} (uid:{member.id}) "
"in {member.guild.name} (gid:{member.guild.id}).".format(
member=member,
),
context="SESSION_TRACKER",
post=False
)
session = Session.start(member, after)
async def leave_guild_sessions(client, guild):
"""
`guild_leave` hook.
Close all sessions in the guild when we leave.
"""
sessions = list(Session.sessions[guild.id].values())
for session in sessions:
session.finish()
client.log(
"Left {} (gid:{}) and closed {} ongoing study sessions.".format(guild.name, guild.id, len(sessions)),
context="SESSION_TRACKER"
)
async def join_guild_sessions(client, guild):
"""
`guild_join` hook.
Refresh all sessions for the guild when we rejoin.
"""
# Delete existing current sessions, which should have been closed when we left
# It is possible we were removed from the guild during an outage
current_sessions.delete_where(guildid=guild.id)
untracked = untracked_channels.get(guild.id).data
members = [
member
for channel in guild.voice_channels
for member in channel.members
if channel.members and channel.id not in untracked and not member.bot
]
for member in members:
client.log(
"Starting new session for '{}' (uid: {}) in '{}' (cid: {}) of '{}' (gid: {})".format(
member.name,
member.id,
member.voice.channel.name,
member.voice.channel.id,
member.guild.name,
member.guild.id
),
context="SESSION_TRACKER",
level=logging.INFO,
post=False
)
Session.start(member, member.voice)
# Log newly started sessions
client.log(
"Joined {} (gid:{}) and started {} new study sessions from current voice channel members.".format(
guild.name,
guild.id,
len(members)
),
context="SESSION_TRACKER",
)
async def _init_session_tracker(client):
"""
Load ongoing saved study sessions into the session cache,
update them depending on the current voice states,
and attach the voice event handler.
"""
# Ensure the client caches are ready and guilds are chunked
await client.wait_until_ready()
# Pre-cache the untracked channels
await untracked_channels.launch_task(client)
# Log init start and define logging counters
client.log(
"Loading ongoing study sessions.",
context="SESSION_INIT",
level=logging.DEBUG
)
resumed = 0
ended = 0
# Grab all ongoing sessions from data
rows = current_sessions.fetch_rows_where(guildid=THIS_SHARD)
# Iterate through, resume or end as needed
for row in rows:
if (guild := client.get_guild(row.guildid)) is not None and row.channelid is not None:
try:
# Load the Session
session = Session(row.guildid, row.userid)
# Find the channel and member voice state
voice = None
if channel := guild.get_channel(row.channelid):
voice = next((member.voice for member in channel.members if member.id == row.userid), None)
# Resume or end as required
if voice and voice.channel:
client.log(
"Resuming ongoing session: {}".format(row),
context="SESSION_INIT",
level=logging.DEBUG
)
session.activate()
session.save_live_status(voice)
resumed += 1
else:
client.log(
"Ending already completed session: {}".format(row),
context="SESSION_INIT",
level=logging.DEBUG
)
session.finish()
ended += 1
except Exception:
# Fatal error
client.log(
"Fatal error occurred initialising session: {}\n{}".format(row, traceback.format_exc()),
context="SESSION_INIT",
level=logging.CRITICAL
)
module.ready = False
return
# Log resumed sessions
client.log(
"Resumed {} ongoing study sessions, and ended {}.".format(resumed, ended),
context="SESSION_INIT",
level=logging.INFO
)
# Now iterate through members of all tracked voice channels
# Start sessions if they don't already exist
tracked_channels = [
channel
for guild in client.guilds
for channel in guild.voice_channels
if channel.members and channel.id not in untracked_channels.get(guild.id).data
]
new_members = [
member
for channel in tracked_channels
for member in channel.members
if not member.bot and not Session.get(member.guild.id, member.id)
]
for member in new_members:
client.log(
"Starting new session for '{}' (uid: {}) in '{}' (cid: {}) of '{}' (gid: {})".format(
member.name,
member.id,
member.voice.channel.name,
member.voice.channel.id,
member.guild.name,
member.guild.id
),
context="SESSION_INIT",
level=logging.DEBUG
)
Session.start(member, member.voice)
# Log newly started sessions
client.log(
"Started {} new study sessions from current voice channel members.".format(len(new_members)),
context="SESSION_INIT",
level=logging.INFO
)
# Now that we are in a valid initial state, attach the session event handler
client.add_after_event("voice_state_update", session_voice_tracker)
client.add_after_event("guild_remove", leave_guild_sessions)
client.add_after_event("guild_join", join_guild_sessions)
@module.launch_task
async def launch_session_tracker(client):
"""
Launch the study session initialiser.
Doesn't block on the client being ready.
"""
client.objects['sessions'] = Session.sessions
asyncio.create_task(_init_session_tracker(client))

View File

@@ -0,0 +1,143 @@
import settings
from settings import GuildSettings
from wards import guild_admin
from .data import untracked_channels
@GuildSettings.attach_setting
class untracked_channels(settings.ChannelList, settings.ListData, settings.Setting):
category = "Study Tracking"
attr_name = 'untracked_channels'
_table_interface = untracked_channels
_setting = settings.VoiceChannel
_id_column = 'guildid'
_data_column = 'channelid'
write_ward = guild_admin
display_name = "untracked_channels"
desc = "Channels to ignore for study time tracking."
_force_unique = True
long_desc = (
"Time spent in these voice channels won't add study time or lioncoins to the member."
)
# Flat cache, no need to expire objects
_cache = {}
@property
def success_response(self):
if self.value:
return "The untracked channels have been updated:\n{}".format(self.formatted)
else:
return "Study time will now be counted in all channels."
@classmethod
async def launch_task(cls, client):
"""
Launch initialisation step for the `untracked_channels` setting.
Pre-fill cache for the guilds with currently active voice channels.
"""
active_guildids = [
guild.id
for guild in client.guilds
if any(channel.members for channel in guild.voice_channels)
]
if active_guildids:
cache = {guildid: [] for guildid in active_guildids}
rows = cls._table_interface.select_where(
guildid=active_guildids
)
for row in rows:
cache[row['guildid']].append(row['channelid'])
cls._cache.update(cache)
client.log(
"Cached {} untracked channels for {} active guilds.".format(
len(rows),
len(cache)
),
context="UNTRACKED_CHANNELS"
)
@GuildSettings.attach_setting
class hourly_reward(settings.Integer, settings.GuildSetting):
category = "Study Tracking"
attr_name = "hourly_reward"
_data_column = "study_hourly_reward"
display_name = "hourly_reward"
desc = "Number of LionCoins given per hour of study."
_default = 50
_max = 32767
long_desc = (
"Each spent in a voice channel will reward this number of LionCoins."
)
_accepts = "An integer number of LionCoins to reward."
@property
def success_response(self):
return "Members will be rewarded `{}` LionCoins per hour of study.".format(self.formatted)
@GuildSettings.attach_setting
class hourly_live_bonus(settings.Integer, settings.GuildSetting):
category = "Study Tracking"
attr_name = "hourly_live_bonus"
_data_column = "study_hourly_live_bonus"
display_name = "hourly_live_bonus"
desc = "Number of extra LionCoins given for a full hour of streaming (via go live or video)."
_default = 150
_max = 32767
long_desc = (
"LionCoin bonus earnt for every hour a member streams in a voice channel, including video. "
"This is in addition to the standard `hourly_reward`."
)
_accepts = "An integer number of LionCoins to reward."
@property
def success_response(self):
return "Members will be rewarded an extra `{}` LionCoins per hour if they stream.".format(self.formatted)
@GuildSettings.attach_setting
class daily_study_cap(settings.Duration, settings.GuildSetting):
category = "Study Tracking"
attr_name = "daily_study_cap"
_data_column = "daily_study_cap"
display_name = "daily_study_cap"
desc = "Maximum amount of recorded study time per member per day."
_default = 16 * 60 * 60
_default_multiplier = 60 * 60
_max = 25 * 60 * 60
long_desc = (
"The maximum amount of study time that can be recorded for a member per day, "
"intended to remove system encouragement for unhealthy or obsessive behaviour.\n"
"The member may study for longer, but their sessions will not be tracked. "
"The start and end of the day are determined by the member's configured timezone."
)
@property
def success_response(self):
# Refresh expiry for all sessions in the guild
[session.schedule_expiry() for session in self.client.objects['sessions'][self.id].values()]
return "The maximum tracked daily study time is now {}.".format(self.formatted)

View File

@@ -0,0 +1,109 @@
import itertools
import traceback
import logging
import asyncio
from time import time
from meta import client
from core import Lion
from ..module import module
from .settings import untracked_channels, hourly_reward, hourly_live_bonus
last_scan = {} # guildid -> timestamp
def _scan(guild):
"""
Scan the tracked voice channels and add time and coins to each user.
"""
# Current timestamp
now = time()
# Get last scan timestamp
try:
last = last_scan[guild.id]
except KeyError:
return
finally:
last_scan[guild.id] = now
# Calculate time since last scan
interval = now - last
# Discard if it has been more than 20 minutes (discord outage?)
if interval > 60 * 20:
return
untracked = untracked_channels.get(guild.id).data
guild_hourly_reward = hourly_reward.get(guild.id).data
guild_hourly_live_bonus = hourly_live_bonus.get(guild.id).data
channel_members = (
channel.members for channel in guild.voice_channels if channel.id not in untracked
)
members = itertools.chain(*channel_members)
# TODO filter out blacklisted users
blacklist = client.user_blacklist()
guild_blacklist = client.objects['ignored_members'][guild.id]
for member in members:
if member.bot:
continue
if member.id in blacklist or member.id in guild_blacklist:
continue
lion = Lion.fetch(guild.id, member.id)
# Add time
lion.addTime(interval, flush=False)
# Add coins
hour_reward = guild_hourly_reward
if member.voice.self_stream or member.voice.self_video:
hour_reward += guild_hourly_live_bonus
lion.addCoins(hour_reward * interval / (3600), flush=False, bonus=True)
async def _study_tracker():
"""
Scanner launch loop.
"""
while True:
while not client.is_ready():
await asyncio.sleep(1)
await asyncio.sleep(5)
# Launch scanners on each guild
for guild in client.guilds:
# Short wait to pass control to other asyncio tasks if they need it
await asyncio.sleep(0)
try:
# Scan the guild
_scan(guild)
except Exception:
# Unknown exception. Catch it so the loop doesn't die.
client.log(
"Error while scanning guild '{}'(gid:{})! "
"Exception traceback follows.\n{}".format(
guild.name,
guild.id,
traceback.format_exc()
),
context="VOICE_ACTIVITY_SCANNER",
level=logging.ERROR
)
@module.launch_task
async def launch_study_tracker(client):
# First pre-load the untracked channels
await untracked_channels.launch_task(client)
asyncio.create_task(_study_tracker())
# TODO: Logout handler, sync

Some files were not shown because too many files have changed in this diff Show More