cleanup: Remove pre-rewrite modules.

This commit is contained in:
2023-08-10 16:03:48 +03:00
parent 42e5b62cd6
commit 72a3838da5
86 changed files with 0 additions and 14597 deletions

View File

@@ -1,16 +0,0 @@
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

@@ -1,477 +0,0 @@
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

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

View File

@@ -1,140 +0,0 @@
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

@@ -1,596 +0,0 @@
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

@@ -1,34 +0,0 @@
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

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

View File

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

View File

@@ -1,515 +0,0 @@
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

@@ -1,320 +0,0 @@
from datetime import timedelta
import asyncio
from data.conditions import GEQ
from modules.stats import goals
from ..module import module, ratelimit
from ...cards import WeeklyGoalCard, MonthlyGoalCard
from ...cards import WeeklyStatsCard, MonthlyStatsCard
from ...utils import get_avatar_key, image_as_file
async def _get_weekly_goals(ctx):
# Fetch goal data
goal_row = ctx.client.data.weekly_goals.fetch_or_create(
(ctx.guild.id, ctx.author.id, ctx.alion.week_timestamp)
)
tasklist_rows = ctx.client.data.member_weekly_goal_tasks.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
weekid=ctx.alion.week_timestamp,
_extra="ORDER BY taskid ASC"
)
tasklist = [
(i, task['content'], task['completed'])
for i, task in enumerate(tasklist_rows)
]
day_start = ctx.alion.day_start
week_start = day_start - timedelta(days=day_start.weekday())
# Fetch study data
week_study_time = ctx.client.data.session_history.queries.study_time_since(
ctx.guild.id, ctx.author.id, week_start
)
study_hours = week_study_time // 3600
# Fetch task data
tasks_done = ctx.client.data.tasklist.select_one_where(
userid=ctx.author.id,
completed_at=GEQ(week_start),
select_columns=('COUNT(*)',)
)[0]
# Fetch accountability data
accountability = ctx.client.data.accountability_member_info.select_where(
userid=ctx.author.id,
start_at=GEQ(week_start),
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
)
if len(accountability):
acc_attended = sum(row['attended'] for row in accountability)
acc_total = len(accountability)
acc_rate = acc_attended / acc_total
else:
acc_rate = None
goalpage = await WeeklyGoalCard.request(
name=ctx.author.name,
discrim=f"#{ctx.author.discriminator}",
avatar=get_avatar_key(ctx.client, ctx.author.id),
badges=ctx.alion.profile_tags,
tasks_done=tasks_done,
studied_hours=study_hours,
attendance=acc_rate,
tasks_goal=goal_row.task_goal,
studied_goal=goal_row.study_goal,
goals=tasklist,
date=ctx.alion.day_start,
skin=WeeklyGoalCard.skin_args_for(ctx)
)
return goalpage
@ratelimit.ward()
async def show_weekly_goals(ctx):
image = await _get_weekly_goals(ctx)
await ctx.reply(file=image_as_file(image, 'weekly_stats_1.png'))
goals.display_weekly_goals_for = show_weekly_goals
@module.cmd(
"weekly",
group="Statistics",
desc="View your weekly study statistics!"
)
@ratelimit.ward()
async def cmd_weekly(ctx):
"""
Usage``:
{prefix}weekly
Description:
View your weekly study profile.
See `{prefix}weeklygoals` to edit your goals!
"""
day_start = ctx.alion.day_start
last_week_start = day_start - timedelta(days=7 + day_start.weekday())
history = ctx.client.data.session_history.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
start_time=GEQ(last_week_start - timedelta(days=1)),
select_columns=(
"start_time",
"(start_time + duration * interval '1 second') AS end_time"
),
_extra="ORDER BY start_time ASC"
)
timezone = ctx.alion.timezone
sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history]
page_1_task = asyncio.create_task(_get_weekly_goals(ctx))
page_2_task = asyncio.create_task(
WeeklyStatsCard.request(
ctx.author.name,
f"#{ctx.author.discriminator}",
sessions,
day_start,
skin=WeeklyStatsCard.skin_args_for(ctx)
)
)
await asyncio.gather(page_1_task, page_2_task)
page_1 = page_1_task.result()
page_2 = page_2_task.result()
await ctx.reply(
files=[
image_as_file(page_1, "weekly_stats_1.png"),
image_as_file(page_2, "weekly_stats_2.png")
]
)
async def _get_monthly_goals(ctx):
# Fetch goal data
goal_row = ctx.client.data.monthly_goals.fetch_or_create(
(ctx.guild.id, ctx.author.id, ctx.alion.month_timestamp)
)
tasklist_rows = ctx.client.data.member_monthly_goal_tasks.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
monthid=ctx.alion.month_timestamp,
_extra="ORDER BY taskid ASC"
)
tasklist = [
(i, task['content'], task['completed'])
for i, task in enumerate(tasklist_rows)
]
day_start = ctx.alion.day_start
month_start = day_start.replace(day=1)
# Fetch study data
month_study_time = ctx.client.data.session_history.queries.study_time_since(
ctx.guild.id, ctx.author.id, month_start
)
study_hours = month_study_time // 3600
# Fetch task data
tasks_done = ctx.client.data.tasklist.select_one_where(
userid=ctx.author.id,
completed_at=GEQ(month_start),
select_columns=('COUNT(*)',)
)[0]
# Fetch accountability data
accountability = ctx.client.data.accountability_member_info.select_where(
userid=ctx.author.id,
start_at=GEQ(month_start),
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
)
if len(accountability):
acc_attended = sum(row['attended'] for row in accountability)
acc_total = len(accountability)
acc_rate = acc_attended / acc_total
else:
acc_rate = None
goalpage = await MonthlyGoalCard.request(
name=ctx.author.name,
discrim=f"#{ctx.author.discriminator}",
avatar=get_avatar_key(ctx.client, ctx.author.id),
badges=ctx.alion.profile_tags,
tasks_done=tasks_done,
studied_hours=study_hours,
attendance=acc_rate,
tasks_goal=goal_row.task_goal,
studied_goal=goal_row.study_goal,
goals=tasklist,
date=ctx.alion.day_start,
skin=MonthlyGoalCard.skin_args_for(ctx)
)
return goalpage
@ratelimit.ward()
async def show_monthly_goals(ctx):
image = await _get_monthly_goals(ctx)
await ctx.reply(file=image_as_file(image, 'monthly_stats_1.png'))
goals.display_monthly_goals_for = show_monthly_goals
@module.cmd(
"monthly",
group="Statistics",
desc="View your monthly study statistics!"
)
async def cmd_monthly(ctx):
"""
Usage``:
{prefix}monthly
Description:
View your monthly study profile.
See `{prefix}monthlygoals` to edit your goals!
"""
day_start = ctx.alion.day_start
period_start = day_start - timedelta(days=31*4)
history = ctx.client.data.session_history.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
select_columns=(
"start_time",
"(start_time + duration * interval '1 second') AS end_time"
),
_extra="ORDER BY start_time DESC"
)
timezone = ctx.alion.timezone
sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history]
if not sessions:
return await ctx.error_reply(
"No statistics to show, because you have never studied in this server before!"
)
# Streak statistics
streak = 0
current_streak = None
max_streak = 0
day_attended = True if 'sessions' in ctx.client.objects and ctx.alion.session else None
date = day_start
daydiff = timedelta(days=1)
periods = sessions
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
first_session_start = sessions[-1][0]
sessions = [session for session in sessions if session[1] > period_start]
page_1_task = asyncio.create_task(_get_monthly_goals(ctx))
page_2_task = asyncio.create_task(MonthlyStatsCard.request(
ctx.author.name,
f"#{ctx.author.discriminator}",
sessions,
day_start.date(),
current_streak or 0,
max_streak or 0,
first_session_start,
skin=MonthlyStatsCard.skin_args_for(ctx)
))
await asyncio.gather(page_1_task, page_2_task)
page_1 = page_1_task.result()
page_2 = page_2_task.result()
await ctx.reply(
files=[
image_as_file(page_1, "monthly_stats_1.png"),
image_as_file(page_2, "monthly_stats_2.png")
]
)

View File

@@ -1,198 +0,0 @@
import gc
import asyncio
import discord
from cmdClient.checks import in_guild
import data
from data import tables
from utils.interactive import discord_shield
from meta import conf
from ...cards import LeaderboardCard
from ...utils import image_as_file, edit_files, get_avatar_key
from ..module import module, ratelimit
next_emoji = conf.emojis.forward
my_emoji = conf.emojis.person
prev_emoji = conf.emojis.backward
@module.cmd(
"top",
desc="View the Study Time leaderboard.",
group="Statistics",
aliases=('ttop', 'toptime', 'top100')
)
@in_guild()
@ratelimit.ward(member=False)
async def cmd_top(ctx):
"""
Usage``:
{prefix}top
{prefix}top100
Description:
Display the study time leaderboard, or the top 100.
"""
# 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', 'display_name'),
'_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_rank = None
entries = []
for i, (userid, time, display_name) in enumerate(user_data):
if (member := ctx.guild.get_member(userid)):
name = member.display_name
elif display_name:
name = display_name
else:
name = str(userid)
entries.append(
(userid, i + 1, time, name, get_avatar_key(ctx.client, userid))
)
if ctx.author.id == userid:
author_rank = i + 1
# Break into pages
entry_pages = [entries[i:i+10] for i in range(0, len(entries), 10)]
page_count = len(entry_pages)
author_page = (author_rank - 1) // 10 if author_rank is not None else None
if page_count == 1:
image = await LeaderboardCard.request(
ctx.guild.name,
entries=entry_pages[0],
highlight=author_rank,
skin=LeaderboardCard.skin_args_for(ctx)
)
_file = image_as_file(image, "leaderboard.png")
await ctx.reply(file=_file)
del image
else:
page_i = 0
page_futures = {}
def submit_page_request(i):
if (_existing := page_futures.get(i, None)) is not None:
# A future was already submitted
_future = _existing
else:
_future = asyncio.create_task(
LeaderboardCard.request(
ctx.guild.name,
entries=entry_pages[i % page_count],
highlight=author_rank,
skin=LeaderboardCard.skin_args_for(ctx)
)
)
page_futures[i] = _future
return _future
# Draw first page
out_msg = await ctx.reply(file=image_as_file(await submit_page_request(0), "leaderboard.png"))
# Prefetch likely next page
submit_page_request(author_page or 1)
# Add reactions
try:
await out_msg.add_reaction(prev_emoji)
if author_page is not None:
await out_msg.add_reaction(my_emoji)
await out_msg.add_reaction(next_emoji)
except discord.Forbidden:
perms = ctx.ch.permissions_for(ctx.guild.me)
if not perms.add_reactions:
await ctx.error_reply(
"Cannot page leaderboard because I do not have the `add_reactions` permission!"
)
elif not perms.read_message_history:
await ctx.error_reply(
"Cannot page leaderboard because I do not have the `read_message_history` permission!"
)
else:
await ctx.error_reply(
"Cannot page leaderboard due to insufficient permissions!"
)
return
def reaction_check(reaction, user):
result = reaction.message.id == out_msg.id
result = result and reaction.emoji in [next_emoji, my_emoji, prev_emoji]
result = result and not (user.id == ctx.client.user.id)
return result
while True:
try:
reaction, user = await ctx.client.wait_for('reaction_add', check=reaction_check, timeout=60)
except asyncio.TimeoutError:
break
asyncio.create_task(discord_shield(out_msg.remove_reaction(reaction.emoji, user)))
# Change the page number
if reaction.emoji == next_emoji:
page_i += 1
page_i %= page_count
elif reaction.emoji == prev_emoji:
page_i -= 1
page_i %= page_count
else:
page_i = author_page
# Edit the message
image = await submit_page_request(page_i)
image_file = image_as_file(image, f"leaderboard_{page_i}.png")
await edit_files(
ctx.client._connection.http,
ctx.ch.id,
out_msg.id,
files=[image_file]
)
# Prefetch surrounding pages
submit_page_request((page_i + 1) % page_count)
submit_page_request((page_i - 1) % page_count)
# Clean up reactions
try:
await out_msg.clear_reactions()
except discord.Forbidden:
try:
await out_msg.remove_reaction(next_emoji, ctx.client.user)
await out_msg.remove_reaction(prev_emoji, ctx.client.user)
except discord.NotFound:
pass
except discord.NotFound:
pass
# Delete the image cache and explicit garbage collect
del page_futures
gc.collect()

View File

@@ -1,90 +0,0 @@
import logging
import time
import traceback
import discord
from LionModule import LionModule
from meta import client
from utils.ratelimits import RateLimit
from ..client import EmptyResponse, request
class PluginModule(LionModule):
def cmd(self, name, **kwargs):
# Remove any existing command with this name
for module in client.modules:
for i, cmd in enumerate(module.cmds):
if cmd.name == name:
module.cmds.pop(i)
return super().cmd(name, **kwargs)
async def on_exception(self, ctx, exception):
try:
raise exception
except (ConnectionError, EmptyResponse) as e:
full_traceback = traceback.format_exc()
only_error = "".join(traceback.TracebackException.from_exception(e).format_exception_only())
client.log(
("Caught a communication exception while "
"executing command '{cmdname}' from module '{module}' "
"from user '{message.author}' (uid:{message.author.id}) "
"in guild '{message.guild}' (gid:{guildid}) "
"in channel '{message.channel}' (cid:{message.channel.id}).\n"
"Message Content:\n"
"{content}\n"
"{traceback}\n\n"
"{flat_ctx}").format(
cmdname=ctx.cmd.name,
module=ctx.cmd.module.name,
message=ctx.msg,
guildid=ctx.guild.id if ctx.guild else None,
content='\n'.join('\t' + line for line in ctx.msg.content.splitlines()),
traceback=full_traceback,
flat_ctx=ctx.flatten()
),
context="mid:{}".format(ctx.msg.id),
level=logging.ERROR
)
error_embed = discord.Embed(title="Sorry, something went wrong!")
error_embed.description = (
"An unexpected error occurred while communicating with our rendering server!\n"
"Our development team has been notified, and the issue should be fixed soon.\n"
)
if logging.getLogger().getEffectiveLevel() < logging.INFO:
error_embed.add_field(
name="Exception",
value="`{}`".format(only_error)
)
await ctx.reply(embed=error_embed)
except Exception:
await super().on_exception(ctx, exception)
module = PluginModule("GUI")
ratelimit = RateLimit(5, 30)
logging.getLogger('PIL').setLevel(logging.WARNING)
@module.launch_task
async def ping_server(client):
start = time.time()
try:
await request('ping')
except Exception:
logging.error(
"Failed to ping the rendering server!",
exc_info=True
)
else:
end = time.time()
client.log(
f"Rendering server responded in {end-start:.6f} seconds!",
context="GUI INIT",
)

View File

@@ -1,24 +0,0 @@
import importlib
from .. import drawing
from . import goals, leaderboard, stats, tasklist
from cmdClient import cmd, checks
@cmd("reloadgui",
desc="Reload all GUI drawing modules.")
@checks.is_owner()
async def cmd_reload_gui(ctx):
importlib.reload(drawing.goals)
importlib.reload(drawing.leaderboard)
importlib.reload(drawing.profile)
importlib.reload(drawing.stats)
importlib.reload(drawing.tasklist)
importlib.reload(drawing.weekly)
importlib.reload(drawing.monthly)
importlib.reload(goals)
importlib.reload(leaderboard)
importlib.reload(stats)
importlib.reload(tasklist)
await ctx.reply("GUI plugin reloaded.")

View File

@@ -1,278 +0,0 @@
import asyncio
import time
from datetime import datetime, timedelta
from cmdClient.checks import in_guild
from utils.lib import utc_now
from data import tables
from data.conditions import LEQ
from core import Lion
from LionContext import LionContext as Context
from modules.study.tracking.data import session_history
from modules.stats.achievements import get_achievements_for
from ...cards import StatsCard, ProfileCard
from ...utils import get_avatar_key, image_as_file
from ..module import module, ratelimit
async def get_stats_card_for(ctx: Context, target):
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)
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.bot or 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
month_start = day_start.replace(day=1)
period_timestamps = (
datetime(1970, 1, 1),
month_start,
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 data for the study run view
streaks = []
streak = 0
streak_end = None
date = day_start
daydiff = timedelta(days=1)
if 'sessions' in ctx.client.objects and lion.session:
day_attended = True
streak_end = day_start.day
else:
day_attended = None
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
if streak_end is None:
streak_end = (date - month_start).days + 1
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
if streak_end is None:
streak_end = (date - month_start).days + 1
continue
if streak_end:
streaks.append((streak_end - streak + 1, streak_end))
streak_end = None
streak = 0
if date.month != day_start.month:
break
# Handle loop exit state, i.e. the last streak
if day_attended:
streak += 1
streaks.append((streak_end - streak + 1, streak_end))
# We have all the data for the stats card
return await StatsCard.request(
(time_rank, coin_rank),
list(reversed(study_times)),
workout_total,
streaks,
skin=StatsCard.skin_args_for(ctx)
)
async def get_profile_card_for(ctx: Context, target):
lion = Lion.fetch(ctx.guild.id, target.id)
# Current economy balance (accounting for current session)
coins = lion.coins
season_time = lion.time
# 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
)
if current_badge:
current_rank = (
role.name if (role := ctx.guild.get_role(current_badge.roleid)) else str(current_badge.roleid),
current_badge.required_time // 3600,
next_badge.required_time // 3600 if next_badge else None
)
else:
current_rank = None
if next_badge:
next_next_badge = min(
(badge for badge in guild_badges if badge.required_time > next_badge.required_time),
key=lambda badge: badge.required_time,
default=None
)
next_rank = (
role.name if (role := ctx.guild.get_role(next_badge.roleid)) else str(next_badge.roleid),
next_badge.required_time // 3600,
next_next_badge.required_time // 3600 if next_next_badge else None
)
else:
next_rank = None
achievements = await get_achievements_for(target)
# We have all the data for the profile card
avatar = get_avatar_key(ctx.client, target.id)
return await ProfileCard.request(
target.name,
'#{}'.format(target.discriminator),
coins,
season_time,
avatar=avatar,
gems=ctx.client.data.gem_transactions.queries.get_gems_for(target.id),
gifts=ctx.client.data.gem_transactions.queries.get_gifts_for(target.id),
achievements=[i for i, ach in enumerate(achievements) if ach.level_id > 0],
current_rank=current_rank,
next_rank=next_rank,
badges=lion.profile_tags,
skin=ProfileCard.skin_args_for(ctx)
)
@module.cmd(
"stats",
group="Statistics",
desc="View your server study statistics!"
)
@in_guild()
@ratelimit.ward(member=False)
async def cmd_stats(ctx):
"""
Usage``:
{prefix}stats
{prefix}stats <mention>
Description:
View your study statistics in this server, or those of the mentioned member.
"""
# 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 cards
futures = (
asyncio.create_task(get_profile_card_for(ctx, target)),
asyncio.create_task(get_stats_card_for(ctx, target))
)
await futures[0]
await futures[1]
profile_image = futures[0].result()
stats_image = futures[1].result()
profile_file = image_as_file(profile_image, f"profile_{target.id}.png")
stats_file = image_as_file(stats_image, f"stats_{target.id}.png")
await ctx.reply(files=[profile_file, stats_file])
@module.cmd(
"profile",
group="Statistics",
desc="View your personal study profile!"
)
@in_guild()
@ratelimit.ward(member=False)
async def cmd_profile(ctx):
"""
Usage``:
{prefix}profile
{prefix}profile <mention>
Description:
View your server study profile, or that of 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 profile!")
target = ctx.msg.mentions[0]
else:
target = ctx.author
# System sync
Lion.sync()
# Fetch the cards
profile_image = await get_profile_card_for(ctx, target)
profile_file = image_as_file(profile_image, f"profile_{target.id}.png")
await ctx.reply(file=profile_file)

View File

@@ -1,111 +0,0 @@
import asyncio
import discord
from core import Lion
from meta import client
from modules.todo.Tasklist import Tasklist as TextTasklist
from ...cards import TasklistCard
from ...utils import get_avatar_key, image_as_file, edit_files
widget_help = """
Open your interactive tasklist with `{prefix}todo`, \
then use the following commands to update your tasks. \
The `<taskids>` may be given as comma separated numbers and ranges.
`<taskids>` Toggle the status (done/notdone) of the provided tasks.
`add/+ <task>` Add a new TODO `task`. Each line is added as a separate task.
`d/rm/- <taskids>` Remove the specified tasks.
`c/check <taskids>` Check (mark as done) the specified tasks.
`u/uncheck <taskids>` Uncheck (mark incomplete) the specified tasks.
`cancel` Cancel the interactive tasklist mode.
*You do not need to write `{prefix}todo` before each command when the list is visible.*
**Examples**
`add Read chapter 1` Add a new task `Read chapter 1`.
`e 0 Notes chapter 1` Edit task `0` to say `Notes chapter 1`.
`d 0, 5-7, 9` Delete tasks `0, 5, 6, 7, 9`.
`0, 2-5, 9` Toggle the completion status of tasks `0, 2, 3, 4, 5, 9`.
[Click here to jump back]({jump_link})
"""
class GUITasklist(TextTasklist):
async def _format_tasklist(self):
tasks = [
(i, task.content, bool(task.completed_at))
for (i, task) in enumerate(self.tasklist)
]
avatar = get_avatar_key(client, self.member.id)
lion = Lion.fetch(self.member.guild.id, self.member.id)
date = lion.day_start
self.pages = await TasklistCard.request(
self.member.name,
f"#{self.member.discriminator}",
tasks,
date,
avatar=avatar,
badges=lion.profile_tags,
skin=TasklistCard.skin_args_for(guildid=self.member.guild.id, userid=self.member.id)
)
return self.pages
async def _post(self):
pages = self.pages
message = await self.channel.send(file=image_as_file(pages[self.current_page], "tasklist.png"))
# Add the reactions
self.has_paging = len(pages) > 1
for emoji in (self.paged_reaction_order if self.has_paging else self.non_paged_reaction_order):
await message.add_reaction(emoji)
# Register
if self.message:
self.messages.pop(self.message.id, None)
self.message = message
self.messages[message.id] = self
async def _update(self):
if self.show_help:
embed = discord.Embed(
title="Tasklist widget guide",
description=widget_help.format(
prefix=client.prefix,
jump_link=self.message.jump_url
),
colour=discord.Colour.orange()
)
try:
await self.member.send(embed=embed)
except discord.Forbidden:
await self.channel.send("Could not send you the guide! Please open your DMs first.")
except discord.HTTPException:
pass
self.show_help = False
await edit_files(
self.message._state.http,
self.channel.id,
self.message.id,
files=[image_as_file(self.pages[self.current_page], "tasklist.png")]
)
# Monkey patch the Tasklist fetch method to conditionally point to the GUI tasklist
# TODO: Config setting for text/gui
@classmethod
def fetch_or_create(cls, ctx, flags, member, channel):
factory = TextTasklist if flags['text'] else GUITasklist
tasklist = GUITasklist.active.get((member.id, channel.id), None)
if type(tasklist) != factory:
tasklist = None
return tasklist if tasklist is not None else factory(member, channel)
TextTasklist.fetch_or_create = fetch_or_create

View File

@@ -1,169 +0,0 @@
import asyncio
import time
import logging
import traceback
from collections import defaultdict
import discord
from utils.lib import utc_now
from core import Lion
from meta import client
from modules.study.timers.Timer import Timer
from ...cards import FocusTimerCard, BreakTimerCard
from ...utils import get_avatar_key, image_as_file, edit_files, asset_path
async def status(self):
stage = self.current_stage
name = self.data.pretty_name
remaining = int((stage.end - utc_now()).total_seconds())
duration = int(stage.duration)
next_starts = int(stage.end.timestamp())
users = [
(get_avatar_key(client, member.id),
session.duration if (session := Lion.fetch(member.guild.id, member.id).session) else 0,
session.data.tag if session else None)
for member in self.members
]
if stage.name == 'FOCUS':
card_class = FocusTimerCard
content = f"**Focus!** Session ends <t:{next_starts}:R>."
else:
card_class = BreakTimerCard
content = f"**Have a rest!** Break finishes <t:{next_starts}:R>."
page = await card_class.request(
name,
remaining,
duration,
users=users,
skin=card_class.skin_args_for(guildid=self.data.guildid)
)
return {
'content': content,
'files': [image_as_file(page, name="timer.png")]
}
_guard_delay = 60
_guarded = {} # timer channel id -> (last_executed_time, currently_waiting)
async def guard_request(id):
if (result := _guarded.get(id, None)):
last, currently = result
if currently:
return False
else:
_guarded[id] = (last, True)
await asyncio.sleep(_guard_delay - (time.time() - last))
_guarded[id] = (time.time(), False)
return True
else:
_guarded[id] = (time.time(), False)
return True
async def update_last_status(self):
"""
Update the last posted status message, if it exists.
"""
old_message = self.reaction_message
if not await guard_request(self.channelid):
return
if old_message != self.reaction_message:
return
args = await self.status()
repost = True
if self.reaction_message:
try:
await edit_files(
client._connection.http,
self.reaction_message.channel.id,
self.reaction_message.id,
**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
guild_locks = defaultdict(asyncio.Lock)
async def play_alert(channel: discord.VoiceChannel, alert_file):
if not channel.members:
# Don't notify an empty channel
return
async with guild_locks[channel.guild.id]:
try:
vc = channel.guild.voice_client
if not vc:
vc = await asyncio.wait_for(
channel.connect(timeout=10, reconnect=False),
20
)
elif vc.channel != channel:
await vc.move_to(channel)
except asyncio.TimeoutError:
client.log(
f"Timed out while attempting to connect to '{channel.name}' (cid:{channel.id}) "
f"in '{channel.guild.name}' (gid:{channel.guild.id}).",
context="TIMER_ALERT",
level=logging.WARNING
)
vc = channel.guild.voice_client
if vc:
await vc.disconnect(force=True)
return
audio_stream = open(alert_file, 'rb')
try:
vc.play(discord.PCMAudio(audio_stream), after=lambda e: audio_stream.close())
except discord.HTTPException:
pass
count = 0
while vc.is_playing() and count < 10:
await asyncio.sleep(1)
count += 1
await vc.disconnect(force=True)
async def notify_hook(self, old_stage, new_stage):
try:
if new_stage.name == 'BREAK':
await play_alert(self.channel, asset_path('timer/voice/break_alert.wav'))
else:
await play_alert(self.channel, asset_path('timer/voice/focus_alert.wav'))
except Exception:
full_traceback = traceback.format_exc()
client.log(
f"Caught an unhandled exception while playing timer alert in '{self.channel.name}' (cid:{self.channel.id})"
f" in '{self.channel.guild.name}' (gid:{self.channel.guild.id}).\n"
f"{full_traceback}",
context="TIMER_ALERT",
level=logging.ERROR
)
Timer.status = status
Timer.update_last_status = update_last_status
Timer.notify_hook = notify_hook

View File

@@ -1,43 +0,0 @@
import importlib
from datetime import datetime, timedelta
from data.conditions import GEQ
from ..module import module
from .. import drawing
from ..utils import get_avatar, image_as_file
@module.cmd(
'tasktest'
)
async def cmd_tasktest(ctx):
importlib.reload(drawing.weekly)
WeeklyStatsPage = drawing.weekly.WeeklyStatsPage
day_start = ctx.alion.day_start
last_week_start = day_start - timedelta(days=7 + day_start.weekday())
history = ctx.client.data.session_history.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
start_time=GEQ(last_week_start - timedelta(days=1)),
select_columns=(
"start_time",
"(start_time + duration * interval '1 second') AS end_time"
),
_extra="ORDER BY start_time ASC"
)
timezone = ctx.alion.timezone
sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history]
page = WeeklyStatsPage(
ctx.author.name,
f"#{ctx.author.discriminator}",
sessions,
day_start
)
image = page.draw()
await ctx.reply(file=image_as_file(image, 'weekly_stats.png'))

View File

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

View File

@@ -1,943 +0,0 @@
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

@@ -1,22 +0,0 @@
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

@@ -1,172 +0,0 @@
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

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

View File

@@ -1,257 +0,0 @@
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

@@ -1,590 +0,0 @@
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

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

View File

@@ -1,76 +0,0 @@
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

@@ -1,215 +0,0 @@
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

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

View File

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

View File

@@ -1,321 +0,0 @@
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

@@ -1,9 +0,0 @@
# 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

@@ -1,485 +0,0 @@
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

@@ -1,39 +0,0 @@
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

@@ -1,332 +0,0 @@
"""
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

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

View File

@@ -1,266 +0,0 @@
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

@@ -1,227 +0,0 @@
"""
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

@@ -1,119 +0,0 @@
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

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

View File

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

View File

@@ -1,349 +0,0 @@
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

@@ -1,24 +0,0 @@
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

@@ -1,462 +0,0 @@
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

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

View File

@@ -1,444 +0,0 @@
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._last_voice_update = utc_now()
await asyncio.create_task(
self.channel.edit(name=self.channel_name)
)
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

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

View File

@@ -1,460 +0,0 @@
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

@@ -1,15 +0,0 @@
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

@@ -1,47 +0,0 @@
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

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

View File

@@ -1,167 +0,0 @@
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

@@ -1,86 +0,0 @@
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

@@ -1,504 +0,0 @@
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

@@ -1,143 +0,0 @@
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

@@ -1,109 +0,0 @@
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

View File

@@ -1,7 +0,0 @@
from .module import module
from . import exec_cmds
from . import guild_log
from . import status
from . import blacklist
from . import botconfig

View File

@@ -1,319 +0,0 @@
"""
System admin submodule providing an interface for managing the globally blacklisted guilds and users.
NOTE: Not shard-safe, and will not update across shards.
"""
import discord
from cmdClient.checks import is_owner
from cmdClient.lib import ResponseTimedOut
from meta.sharding import sharded
from .module import module
@module.cmd(
"guildblacklist",
desc="View/add/remove blacklisted guilds.",
group="Bot Admin",
flags=('remove',)
)
@is_owner()
async def cmd_guildblacklist(ctx, flags):
"""
Usage``:
{prefix}guildblacklist
{prefix}guildblacklist guildid, guildid, guildid
{prefix}guildblacklist --remove guildid, guildid, guildid
Description:
View, add, or remove guilds from the blacklist.
"""
blacklist = ctx.client.guild_blacklist()
if ctx.args:
# guildid parsing
items = [item.strip() for item in ctx.args.split(',')]
if any(not item.isdigit() for item in items):
return await ctx.error_reply(
"Please provide guilds as comma separated guild ids."
)
guildids = set(int(item) for item in items)
if flags['remove']:
# Handle removing from the blacklist
# First make sure that all the guildids are in the blacklist
difference = [guildid for guildid in guildids if guildid not in blacklist]
if difference:
return await ctx.error_reply(
"The following guildids are not in the blacklist! No guilds were removed.\n`{}`".format(
'`, `'.join(str(guildid) for guildid in difference)
)
)
# Remove the guilds from the data blacklist
ctx.client.data.global_guild_blacklist.delete_where(
guildid=list(guildids)
)
# Ack removal
await ctx.embed_reply(
"You have removed the following guilds from the guild blacklist.\n`{}`".format(
"`, `".join(str(guildid) for guildid in guildids)
)
)
else:
# Handle adding to the blacklist
to_add = [guildid for guildid in guildids if guildid not in blacklist]
if not to_add:
return await ctx.error_reply(
"All of the provided guilds are already blacklisted!"
)
# Prompt for reason
try:
reason = await ctx.input("Please enter the reasons these guild(s) are being blacklisted:")
except ResponseTimedOut:
raise ResponseTimedOut("Reason prompt timed out, no guilds were blacklisted.")
# Add to the blacklist
ctx.client.data.global_guild_blacklist.insert_many(
*((guildid, ctx.author.id, reason) for guildid in to_add),
insert_keys=('guildid', 'ownerid', 'reason')
)
# Leave freshly blacklisted guilds, accounting for shards
to_leave = []
for guildid in to_add:
guild = ctx.client.get_guild(guildid)
if not guild and sharded:
try:
guild = await ctx.client.fetch_guild(guildid)
except discord.HTTPException:
pass
if guild:
to_leave.append(guild)
for guild in to_leave:
await guild.leave()
if to_leave:
left_str = "\nConsequently left the following guild(s):\n**{}**".format(
'**\n**'.join(guild.name for guild in to_leave)
)
else:
left_str = ""
# Ack the addition
await ctx.embed_reply(
"Added the following guild(s) to the blacklist:\n`{}`\n{}".format(
'`, `'.join(str(guildid) for guildid in to_add),
left_str
)
)
# Refresh the cached blacklist after modification
ctx.client.guild_blacklist.cache_clear()
ctx.client.guild_blacklist()
else:
# Display the current blacklist
# First fetch the full blacklist data
rows = ctx.client.data.global_guild_blacklist.select_where()
if not rows:
await ctx.reply("There are no blacklisted guilds!")
else:
# Text blocks for each blacklisted guild
lines = [
"`{}` blacklisted by <@{}> at <t:{:.0f}>\n**Reason:** {}".format(
row['guildid'],
row['ownerid'],
row['created_at'].timestamp(),
row['reason']
) for row in sorted(rows, key=lambda row: row['created_at'].timestamp(), reverse=True)
]
# Split lines across pages
blocks = []
block_len = 0
block_lines = []
i = 0
while i < len(lines):
line = lines[i]
line_len = len(line)
if block_len + line_len > 2000:
if block_lines:
# Flush block, run line again on next page
blocks.append('\n'.join(block_lines))
block_lines = []
block_len = 0
else:
# Too long for the block, but empty block!
# Truncate
blocks.append(line[:2000])
i += 1
else:
block_lines.append(line)
i += 1
if block_lines:
# Flush block
blocks.append('\n'.join(block_lines))
# Build embed pages
pages = [
discord.Embed(
title="Blacklisted Guilds",
description=block,
colour=discord.Colour.orange()
) for block in blocks
]
page_count = len(blocks)
if page_count > 1:
for i, page in enumerate(pages):
page.set_footer(text="Page {}/{}".format(i + 1, page_count))
# Finally, post
await ctx.pager(pages)
@module.cmd(
"userblacklist",
desc="View/add/remove blacklisted users.",
group="Bot Admin",
flags=('remove',)
)
@is_owner()
async def cmd_userblacklist(ctx, flags):
"""
Usage``:
{prefix}userblacklist
{prefix}userblacklist userid, userid, userid
{prefix}userblacklist --remove userid, userid, userid
Description:
View, add, or remove users from the blacklist.
"""
blacklist = ctx.client.user_blacklist()
if ctx.args:
# userid parsing
items = [item.strip('<@!&> ') for item in ctx.args.split(',')]
if any(not item.isdigit() for item in items):
return await ctx.error_reply(
"Please provide users as comma seprated user ids or mentions."
)
userids = set(int(item) for item in items)
if flags['remove']:
# Handle removing from the blacklist
# First make sure that all the userids are in the blacklist
difference = [userid for userid in userids if userid not in blacklist]
if difference:
return await ctx.error_reply(
"The following userids are not in the blacklist! No users were removed.\n`{}`".format(
'`, `'.join(str(userid) for userid in difference)
)
)
# Remove the users from the data blacklist
ctx.client.data.global_user_blacklist.delete_where(
userid=list(userids)
)
# Ack removal
await ctx.embed_reply(
"You have removed the following users from the user blacklist.\n{}".format(
", ".join('<@{}>'.format(userid) for userid in userids)
)
)
else:
# Handle adding to the blacklist
to_add = [userid for userid in userids if userid not in blacklist]
if not to_add:
return await ctx.error_reply(
"All of the provided users are already blacklisted!"
)
# Prompt for reason
try:
reason = await ctx.input("Please enter the reasons these user(s) are being blacklisted:")
except ResponseTimedOut:
raise ResponseTimedOut("Reason prompt timed out, no users were blacklisted.")
# Add to the blacklist
ctx.client.data.global_user_blacklist.insert_many(
*((userid, ctx.author.id, reason) for userid in to_add),
insert_keys=('userid', 'ownerid', 'reason')
)
# Ack the addition
await ctx.embed_reply(
"Added the following user(s) to the blacklist:\n{}".format(
', '.join('<@{}>'.format(userid) for userid in to_add)
)
)
# Refresh the cached blacklist after modification
ctx.client.user_blacklist.cache_clear()
ctx.client.user_blacklist()
else:
# Display the current blacklist
# First fetch the full blacklist data
rows = ctx.client.data.global_user_blacklist.select_where()
if not rows:
await ctx.reply("There are no blacklisted users!")
else:
# Text blocks for each blacklisted user
lines = [
"<@{}> blacklisted by <@{}> at <t:{:.0f}>\n**Reason:** {}".format(
row['userid'],
row['ownerid'],
row['created_at'].timestamp(),
row['reason']
) for row in sorted(rows, key=lambda row: row['created_at'].timestamp(), reverse=True)
]
# Split lines across pages
blocks = []
block_len = 0
block_lines = []
i = 0
while i < len(lines):
line = lines[i]
line_len = len(line)
if block_len + line_len > 2000:
if block_lines:
# Flush block, run line again on next page
blocks.append('\n'.join(block_lines))
block_lines = []
block_len = 0
else:
# Too long for the block, but empty block!
# Truncate
blocks.append(line[:2000])
i += 1
else:
block_lines.append(line)
i += 1
if block_lines:
# Flush block
blocks.append('\n'.join(block_lines))
# Build embed pages
pages = [
discord.Embed(
title="Blacklisted Users",
description=block,
colour=discord.Colour.orange()
) for block in blocks
]
page_count = len(blocks)
if page_count > 1:
for i, page in enumerate(pages):
page.set_footer(text="Page {}/{}".format(i + 1, page_count))
# Finally, post
await ctx.pager(pages)

View File

@@ -1,96 +0,0 @@
import difflib
import discord
from cmdClient.checks import is_owner
from settings import UserInputError
from utils.lib import prop_tabulate
from .module import module
@module.cmd("botconfig",
desc="Update global bot configuration.",
flags=('add', 'remove'),
group="Bot Admin")
@is_owner()
async def cmd_botconfig(ctx, flags):
"""
Usage``
{prefix}botconfig
{prefix}botconfig info
{prefix}botconfig <setting>
{prefix}botconfig <setting> <value>
Description:
Usage directly follows the `config` command for guild configuration.
"""
# Cache and map some info for faster access
setting_displaynames = {setting.display_name.lower(): setting for setting in ctx.client.settings.settings.values()}
appid = ctx.client.conf['data_appid']
if not ctx.args or ctx.args.lower() in ('info', 'help'):
# Fill the setting cats
cats = {}
for setting in ctx.client.settings.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(appid).summary if not ctx.args else setting.desc
for setting in cat
}
# TODO: Add cat description here
sections[catname] = prop_tabulate(*zip(*catprops.items()))
# Build the cat page
embed = discord.Embed(
colour=discord.Colour.orange(),
title="App Configuration"
)
for name, section in sections.items():
embed.add_field(name=name, value=section, inline=False)
await ctx.reply(embed=embed)
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 `{}botconfig info` to see all the available 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(appid).widget(ctx, flags=flags)
else:
# config <setting> <value>
# Attempt to set config setting
try:
parsed = await setting.parse(appid, 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(appid).success_response),
colour=discord.Colour.green()
))

View File

@@ -1,135 +0,0 @@
import sys
from io import StringIO
import traceback
import asyncio
from cmdClient import cmd, checks
from core import Lion
from LionModule import LionModule
"""
Exec level commands to manage the bot.
Commands provided:
async:
Executes provided code in an async executor
eval:
Executes code and awaits it if required
"""
@cmd("shutdown",
desc="Sync data and shutdown.",
group="Bot Admin",
aliases=('restart', 'reboot'))
@checks.is_owner()
async def cmd_shutdown(ctx):
"""
Usage``:
reboot
Description:
Run unload tasks and shutdown/reboot.
"""
# Run module logout tasks
for module in ctx.client.modules:
if isinstance(module, LionModule):
await module.unload(ctx.client)
# Reply and logout
await ctx.reply("All modules synced. Shutting down!")
await ctx.client.close()
@cmd("async",
desc="Execute arbitrary code with `async`.",
group="Bot Admin")
@checks.is_owner()
async def cmd_async(ctx):
"""
Usage:
{prefix}async <code>
Description:
Runs <code> as an asynchronous coroutine and prints the output or error.
"""
if ctx.arg_str == "":
await ctx.error_reply("You must give me something to run!")
return
output, error = await _async(ctx)
await ctx.reply(
"**Async input:**\
\n```py\n{}\n```\
\n**Output {}:** \
\n```py\n{}\n```".format(ctx.arg_str,
"error" if error else "",
output))
@cmd("eval",
desc="Execute arbitrary code with `eval`.",
group="Bot Admin")
@checks.is_owner()
async def cmd_eval(ctx):
"""
Usage:
{prefix}eval <code>
Description:
Runs <code> in current environment using eval() and prints the output or error.
"""
if ctx.arg_str == "":
await ctx.error_reply("You must give me something to run!")
return
output, error = await _eval(ctx)
await ctx.reply(
"**Eval input:**\
\n```py\n{}\n```\
\n**Output {}:** \
\n```py\n{}\n```".format(ctx.arg_str,
"error" if error else "",
output)
)
async def _eval(ctx):
output = None
try:
output = eval(ctx.arg_str)
except Exception:
return (str(traceback.format_exc()), 1)
if asyncio.iscoroutine(output):
output = await output
return (output, 0)
async def _async(ctx):
env = {
'ctx': ctx,
'client': ctx.client,
'message': ctx.msg,
'arg_str': ctx.arg_str
}
env.update(globals())
old_stdout = sys.stdout
redirected_output = sys.stdout = StringIO()
result = None
exec_string = "async def _temp_exec():\n"
exec_string += '\n'.join(' ' * 4 + line for line in ctx.arg_str.split('\n'))
try:
exec(exec_string, env)
result = (redirected_output.getvalue(), 0)
except Exception:
result = (str(traceback.format_exc()), 1)
return result
_temp_exec = env['_temp_exec']
try:
returnval = await _temp_exec()
value = redirected_output.getvalue()
if returnval is None:
result = (value, 0)
else:
result = (value + '\n' + str(returnval), 0)
except Exception:
result = (str(traceback.format_exc()), 1)
finally:
sys.stdout = old_stdout
return result

View File

@@ -1,83 +0,0 @@
import datetime
import discord
from meta import client, conf
from utils.lib import mail
@client.add_after_event("guild_remove")
async def log_left_guild(client, guild):
# Build embed
embed = discord.Embed(title="`{0.name} (ID: {0.id})`".format(guild),
colour=discord.Colour.red(),
timestamp=datetime.datetime.utcnow())
embed.set_author(name="Left guild!")
embed.set_thumbnail(url=guild.icon_url)
# Add more specific information about the guild
embed.add_field(name="Owner", value="{0.name} (ID: {0.id})".format(guild.owner), inline=False)
embed.add_field(name="Members (cached)", value="{}".format(len(guild.members)), inline=False)
embed.add_field(name="Now studying in", value="{} guilds".format(len(client.guilds)), inline=False)
# Retrieve the guild log channel and log the event
log_chid = conf.bot.get("guild_log_channel")
if log_chid:
await mail(client, log_chid, embed=embed)
@client.add_after_event("guild_join")
async def log_joined_guild(client, guild):
owner = guild.owner
icon = guild.icon_url
bots = 0
known = 0
unknown = 0
other_members = set(mem.id for mem in client.get_all_members() if mem.guild != guild)
for member in guild.members:
if member.bot:
bots += 1
elif member.id in other_members:
known += 1
else:
unknown += 1
mem1 = "people I know" if known != 1 else "person I know"
mem2 = "new friends" if unknown != 1 else "new friend"
mem3 = "bots" if bots != 1 else "bot"
mem4 = "total members"
known = "`{}`".format(known)
unknown = "`{}`".format(unknown)
bots = "`{}`".format(bots)
total = "`{}`".format(guild.member_count)
mem_str = "{0:<5}\t{4},\n{1:<5}\t{5},\n{2:<5}\t{6}, and\n{3:<5}\t{7}.".format(
known,
unknown,
bots,
total,
mem1,
mem2,
mem3,
mem4
)
region = str(guild.region)
created = "<t:{}>".format(int(guild.created_at.timestamp()))
embed = discord.Embed(
title="`{0.name} (ID: {0.id})`".format(guild),
colour=discord.Colour.green(),
timestamp=datetime.datetime.utcnow()
)
embed.set_author(name="Joined guild!")
embed.add_field(name="Owner", value="{0} (ID: {0.id})".format(owner), inline=False)
embed.add_field(name="Region", value=region, inline=False)
embed.add_field(name="Created at", value=created, inline=False)
embed.add_field(name="Members", value=mem_str, inline=False)
embed.add_field(name="Now studying in", value="{} guilds".format(len(client.guilds)), inline=False)
# Retrieve the guild log channel and log the event
log_chid = conf.bot.get("guild_log_channel")
if log_chid:
await mail(client, log_chid, embed=embed)

View File

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

View File

@@ -1,53 +0,0 @@
import time
import asyncio
import discord
from meta import client
from .module import module
_last_update = 0
async def update_status():
# TODO: Make globally configurable and saveable
global _last_update
if time.time() - _last_update < 60:
return
_last_update = time.time()
student_count, room_count = client.data.current_sessions.select_one_where(
select_columns=("COUNT(*) AS studying_count", "COUNT(DISTINCT(channelid)) AS channel_count"),
)
status = "{} students in {} study rooms!".format(student_count, room_count)
await client.change_presence(
activity=discord.Activity(
type=discord.ActivityType.watching,
name=status
)
)
@client.add_after_event("voice_state_update")
async def trigger_status_update(client, member, before, after):
if before.channel != after.channel:
await update_status()
async def _status_loop():
while not client.is_ready():
await asyncio.sleep(5)
while True:
try:
await update_status()
except discord.HTTPException:
pass
await asyncio.sleep(300)
@module.launch_task
async def launch_status_update(client):
asyncio.create_task(_status_loop())

View File

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

View File

@@ -1,28 +0,0 @@
from .module import module
from settings import UserSettings
@module.cmd(
"mytz",
group="Personal Settings",
desc=("Timezone used to display prompts. "
"(Currently {ctx.author_settings.timezone.formatted})"),
)
async def cmd_mytimezone(ctx):
"""
Usage``:
{prefix}mytz
{prefix}mytz <tz name>
Setting Description:
{ctx.author_settings.settings.timezone.long_desc}
Accepted Values:
Timezone names must be from the "TZ Database Name" column of \
[this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
For example, `Europe/London`, `Australia/Melbourne`, or `America/New_York`.
Partial names are also accepted.
Examples``:
{prefix}mytz Europe/London
{prefix}mytz London
"""
await UserSettings.settings.timezone.command(ctx, ctx.author.id)

View File

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

View File

@@ -1,88 +0,0 @@
import types
from cmdClient import Context
from cmdClient.logger import log
class LionContext(Context):
"""
Subclass to allow easy attachment of custom hooks and structure to contexts.
"""
__slots__ = ()
@classmethod
def util(cls, util_func):
"""
Decorator to make a utility function available as a Context instance method.
Extends the default Context method to add logging and to return the utility function.
"""
super().util(util_func)
log(f"Attached context utility function: {util_func.__name__}")
return util_func
@classmethod
def wrappable_util(cls, util_func):
"""
Decorator to add a Wrappable utility function as a Context instance method.
"""
wrappable = Wrappable(util_func)
super().util(wrappable)
log(f"Attached wrappable context utility function: {util_func.__name__}")
return wrappable
class Wrappable:
__slots__ = ('_func', 'wrappers')
def __init__(self, func):
self._func = func
self.wrappers = None
@property
def __name__(self):
return self._func.__name__
def add_wrapper(self, func, name=None):
self.wrappers = self.wrappers or {}
name = name or func.__name__
self.wrappers[name] = func
log(
f"Added wrapper '{name}' to Wrappable '{self._func.__name__}'.",
context="Wrapping"
)
def remove_wrapper(self, name):
if not self.wrappers or name not in self.wrappers:
raise ValueError(
f"Cannot remove non-existent wrapper '{name}' from Wrappable '{self._func.__name__}'"
)
self.wrappers.pop(name)
log(
f"Removed wrapper '{name}' from Wrappable '{self._func.__name__}'.",
context="Wrapping"
)
def __call__(self, *args, **kwargs):
if self.wrappers:
return self._wrapped(iter(self.wrappers.values()))(*args, **kwargs)
else:
return self._func(*args, **kwargs)
def _wrapped(self, iter_wraps):
next_wrap = next(iter_wraps, None)
if next_wrap:
def _func(*args, **kwargs):
return next_wrap(self._wrapped(iter_wraps), *args, **kwargs)
else:
_func = self._func
return _func
def __get__(self, instance, cls=None):
if instance is None:
return self
else:
return types.MethodType(self, instance)
# Override the original Context.reply with a wrappable utility
reply = LionContext.wrappable_util(Context.reply)

View File

@@ -1,186 +0,0 @@
import asyncio
import traceback
import logging
import discord
from cmdClient import Command, Module, FailedCheck
from cmdClient.lib import SafeCancellation
from meta import log
class LionCommand(Command):
"""
Subclass to allow easy attachment of custom hooks and structure to commands.
"""
allow_before_ready = False
class LionModule(Module):
"""
Custom module for Lion systems.
Adds command wrappers and various event handlers.
"""
name = "Base Lion Module"
def __init__(self, name, baseCommand=LionCommand):
super().__init__(name, baseCommand)
self.unload_tasks = []
def unload_task(self, func):
"""
Decorator adding an unload task for deactivating the module.
Should sync unsaved transactions and finalise user interaction.
If possible, should also remove attached data and handlers.
"""
self.unload_tasks.append(func)
log("Adding unload task '{}'.".format(func.__name__), context=self.name)
return func
async def unload(self, client):
"""
Run the unloading tasks.
"""
log("Unloading module.", context=self.name, post=False)
for task in self.unload_tasks:
log("Running unload task '{}'".format(task.__name__),
context=self.name, post=False)
await task(client)
async def launch(self, client):
"""
Launch hook.
Executed in `client.on_ready`.
Must set `ready` to `True`, otherwise all commands will hang.
Overrides the parent launcher to not post the log as a discord message.
"""
if not self.ready:
log("Running launch tasks.", context=self.name, post=False)
for task in self.launch_tasks:
log("Running launch task '{}'.".format(task.__name__),
context=self.name, post=False)
await task(client)
self.ready = True
else:
log("Already launched, skipping launch.", context=self.name, post=False)
async def pre_command(self, ctx):
"""
Lion pre-command hook.
"""
if not self.ready and not ctx.cmd.allow_before_ready:
try:
await ctx.embed_reply(
"I am currently restarting! Please try again in a couple of minutes."
)
except discord.HTTPException:
pass
raise SafeCancellation(details="Module '{}' is not ready.".format(self.name))
# Check global user blacklist
if ctx.author.id in ctx.client.user_blacklist():
raise SafeCancellation(details='User is blacklisted.')
if ctx.guild:
# Check that the channel and guild still exists
if not ctx.client.get_guild(ctx.guild.id) or not ctx.guild.get_channel(ctx.ch.id):
raise SafeCancellation(details='Command channel is no longer reachable.')
# Check global guild blacklist
if ctx.guild.id in ctx.client.guild_blacklist():
raise SafeCancellation(details='Guild is blacklisted.')
# Check guild's own member blacklist
if ctx.author.id in ctx.client.objects['ignored_members'][ctx.guild.id]:
raise SafeCancellation(details='User is ignored in this guild.')
# Check channel permissions are sane
if not ctx.ch.permissions_for(ctx.guild.me).send_messages:
raise SafeCancellation(details='I cannot send messages in this channel.')
if not ctx.ch.permissions_for(ctx.guild.me).embed_links:
await ctx.reply("I need permission to send embeds in this channel before I can run any commands!")
raise SafeCancellation(details='I cannot send embeds in this channel.')
# Ensure Lion exists and cached data is up to date
ctx.alion.update_saved_data(ctx.author)
# Start typing
await ctx.ch.trigger_typing()
async def on_exception(self, ctx, exception):
try:
raise exception
except (FailedCheck, SafeCancellation):
# cmdClient generated and handled exceptions
raise exception
except (asyncio.CancelledError, asyncio.TimeoutError):
# Standard command and task exceptions, cmdClient will also handle these
raise exception
except discord.Forbidden:
# Unknown uncaught Forbidden
try:
# Attempt a general error reply
await ctx.reply("I don't have enough channel or server permissions to complete that command here!")
except discord.Forbidden:
# We can't send anything at all. Exit quietly, but log.
full_traceback = traceback.format_exc()
log(("Caught an unhandled 'Forbidden' while "
"executing command '{cmdname}' from module '{module}' "
"from user '{message.author}' (uid:{message.author.id}) "
"in guild '{message.guild}' (gid:{guildid}) "
"in channel '{message.channel}' (cid:{message.channel.id}).\n"
"Message Content:\n"
"{content}\n"
"{traceback}\n\n"
"{flat_ctx}").format(
cmdname=ctx.cmd.name,
module=ctx.cmd.module.name,
message=ctx.msg,
guildid=ctx.guild.id if ctx.guild else None,
content='\n'.join('\t' + line for line in ctx.msg.content.splitlines()),
traceback=full_traceback,
flat_ctx=ctx.flatten()
),
context="mid:{}".format(ctx.msg.id),
level=logging.WARNING)
except Exception as e:
# Unknown exception!
full_traceback = traceback.format_exc()
only_error = "".join(traceback.TracebackException.from_exception(e).format_exception_only())
log(("Caught an unhandled exception while "
"executing command '{cmdname}' from module '{module}' "
"from user '{message.author}' (uid:{message.author.id}) "
"in guild '{message.guild}' (gid:{guildid}) "
"in channel '{message.channel}' (cid:{message.channel.id}).\n"
"Message Content:\n"
"{content}\n"
"{traceback}\n\n"
"{flat_ctx}").format(
cmdname=ctx.cmd.name,
module=ctx.cmd.module.name,
message=ctx.msg,
guildid=ctx.guild.id if ctx.guild else None,
content='\n'.join('\t' + line for line in ctx.msg.content.splitlines()),
traceback=full_traceback,
flat_ctx=ctx.flatten()
),
context="mid:{}".format(ctx.msg.id),
level=logging.ERROR)
error_embed = discord.Embed(title="Something went wrong!")
error_embed.description = (
"An unexpected error occurred while processing your command!\n"
"Our development team has been notified, and the issue should be fixed soon.\n"
)
if logging.getLogger().getEffectiveLevel() < logging.INFO:
error_embed.add_field(
name="Exception",
value="`{}`".format(only_error)
)
await ctx.reply(embed=error_embed)

View File

@@ -1,5 +0,0 @@
from . import data # noqa
from .module import module
from .lion import Lion
from . import blacklists

View File

@@ -1,92 +0,0 @@
"""
Guild, user, and member blacklists.
"""
from collections import defaultdict
import cachetools.func
from data import tables
from meta import client
from .module import module
@cachetools.func.ttl_cache(ttl=300)
def guild_blacklist():
"""
Get the guild blacklist
"""
rows = tables.global_guild_blacklist.select_where()
return set(row['guildid'] for row in rows)
@cachetools.func.ttl_cache(ttl=300)
def user_blacklist():
"""
Get the global user blacklist.
"""
rows = tables.global_user_blacklist.select_where()
return set(row['userid'] for row in rows)
@module.init_task
def load_ignored_members(client):
"""
Load the ignored members.
"""
ignored = defaultdict(set)
rows = tables.ignored_members.select_where()
for row in rows:
ignored[row['guildid']].add(row['userid'])
client.objects['ignored_members'] = ignored
if rows:
client.log(
"Loaded {} ignored members across {} guilds.".format(
len(rows),
len(ignored)
),
context="MEMBER_BLACKLIST"
)
@module.init_task
def attach_client_blacklists(client):
client.guild_blacklist = guild_blacklist
client.user_blacklist = user_blacklist
@module.launch_task
async def leave_blacklisted_guilds(client):
"""
Launch task to leave any blacklisted guilds we are in.
"""
to_leave = [
guild for guild in client.guilds
if guild.id in guild_blacklist()
]
for guild in to_leave:
await guild.leave()
if to_leave:
client.log(
"Left {} blacklisted guilds!".format(len(to_leave)),
context="GUILD_BLACKLIST"
)
@client.add_after_event('guild_join')
async def check_guild_blacklist(client, guild):
"""
Guild join event handler to check whether the guild is blacklisted.
If so, leaves the guild.
"""
# First refresh the blacklist cache
if guild.id in guild_blacklist():
await guild.leave()
client.log(
"Automatically left blacklisted guild '{}' (gid:{}) upon join.".format(guild.name, guild.id),
context="GUILD_BLACKLIST"
)

View File

@@ -1,128 +0,0 @@
from psycopg2.extras import execute_values
from cachetools import TTLCache
from data import RowTable, Table
meta = RowTable(
'AppData',
('appid', 'last_study_badge_scan'),
'appid',
attach_as='meta',
)
# TODO: Consider converting to RowTable for per-shard config caching
app_config = Table('AppConfig')
user_config = RowTable(
'user_config',
('userid', 'timezone', 'topgg_vote_reminder', 'avatar_hash', 'gems'),
'userid',
cache=TTLCache(5000, ttl=60*5)
)
guild_config = RowTable(
'guild_config',
('guildid', 'admin_role', 'mod_role', 'event_log_channel', 'mod_log_channel', 'alert_channel',
'studyban_role', 'max_study_bans',
'min_workout_length', 'workout_reward',
'max_tasks', 'task_reward', 'task_reward_limit',
'study_hourly_reward', 'study_hourly_live_bonus', 'daily_study_cap',
'renting_price', 'renting_category', 'renting_cap', 'renting_role', 'renting_sync_perms',
'accountability_category', 'accountability_lobby', 'accountability_bonus',
'accountability_reward', 'accountability_price',
'video_studyban', 'video_grace_period',
'greeting_channel', 'greeting_message', 'returning_message',
'starting_funds', 'persist_roles',
'pomodoro_channel',
'name'),
'guildid',
cache=TTLCache(2500, ttl=60*5)
)
unranked_roles = Table('unranked_roles')
donator_roles = Table('donator_roles')
lions = RowTable(
'members',
('guildid', 'userid',
'tracked_time', 'coins',
'workout_count', 'last_workout_start',
'revision_mute_count',
'last_study_badgeid',
'video_warned',
'display_name',
'_timestamp'
),
('guildid', 'userid'),
cache=TTLCache(5000, ttl=60*5),
attach_as='lions'
)
@lions.save_query
def add_pending(pending):
"""
pending:
List of tuples of the form `(guildid, userid, pending_coins)`.
"""
with lions.conn:
cursor = lions.conn.cursor()
data = execute_values(
cursor,
"""
UPDATE members
SET
coins = LEAST(coins + t.coin_diff, 2147483647)
FROM
(VALUES %s)
AS
t (guildid, userid, coin_diff)
WHERE
members.guildid = t.guildid
AND
members.userid = t.userid
RETURNING *
""",
pending,
fetch=True
)
return lions._make_rows(*data)
lion_ranks = Table('member_ranks', attach_as='lion_ranks')
@lions.save_query
def get_member_rank(guildid, userid, untracked):
"""
Get the time and coin ranking for the given member, ignoring the provided untracked members.
"""
with lions.conn as conn:
with conn.cursor() as curs:
curs.execute(
"""
SELECT
time_rank, coin_rank
FROM (
SELECT
userid,
row_number() OVER (ORDER BY total_tracked_time DESC, userid ASC) AS time_rank,
row_number() OVER (ORDER BY total_coins DESC, userid ASC) AS coin_rank
FROM members_totals
WHERE
guildid=%s AND userid NOT IN %s
) AS guild_ranks WHERE userid=%s
""",
(guildid, tuple(untracked), userid)
)
return curs.fetchone() or (None, None)
global_guild_blacklist = Table('global_guild_blacklist')
global_user_blacklist = Table('global_user_blacklist')
ignored_members = Table('ignored_members')

View File

@@ -1,347 +0,0 @@
import pytz
import discord
from functools import reduce
from datetime import datetime, timedelta
from meta import client
from data import tables as tb
from settings import UserSettings, GuildSettings
from LionContext import LionContext
class Lion:
"""
Class representing a guild Member.
Mostly acts as a transparent interface to the corresponding Row,
but also adds some transaction caching logic to `coins` and `tracked_time`.
"""
__slots__ = ('guildid', 'userid', '_pending_coins', '_member')
# Members with pending transactions
_pending = {} # userid -> User
# Lion cache. Currently lions don't expire
_lions = {} # (guildid, userid) -> Lion
# Extra methods supplying an economy bonus
_economy_bonuses = []
def __init__(self, guildid, userid):
self.guildid = guildid
self.userid = userid
self._pending_coins = 0
self._member = None
self._lions[self.key] = self
@classmethod
def fetch(cls, guildid, userid):
"""
Fetch a Lion with the given member.
If they don't exist, creates them.
If possible, retrieves the user from the user cache.
"""
key = (guildid, userid)
if key in cls._lions:
return cls._lions[key]
else:
# TODO: Debug log
lion = tb.lions.fetch(key)
if not lion:
tb.user_config.fetch_or_create(userid)
tb.guild_config.fetch_or_create(guildid)
tb.lions.create_row(
guildid=guildid,
userid=userid,
coins=GuildSettings(guildid).starting_funds.value
)
return cls(guildid, userid)
@property
def key(self):
return (self.guildid, self.userid)
@property
def guild(self) -> discord.Guild:
return client.get_guild(self.guildid)
@property
def member(self) -> discord.Member:
"""
The discord `Member` corresponding to this user.
May be `None` if the member is no longer in the guild or the caches aren't populated.
Not guaranteed to be `None` if the member is not in the guild.
"""
if self._member is None:
guild = client.get_guild(self.guildid)
if guild:
self._member = guild.get_member(self.userid)
return self._member
@property
def data(self):
"""
The Row corresponding to this member.
"""
return tb.lions.fetch(self.key)
@property
def user_data(self):
"""
The Row corresponding to this user.
"""
return tb.user_config.fetch_or_create(self.userid)
@property
def guild_data(self):
"""
The Row corresponding to this guild.
"""
return tb.guild_config.fetch_or_create(self.guildid)
@property
def settings(self):
"""
The UserSettings interface for this member.
"""
return UserSettings(self.userid)
@property
def guild_settings(self):
"""
The GuildSettings interface for this member.
"""
return GuildSettings(self.guildid)
@property
def ctx(self) -> LionContext:
"""
Manufacture a `LionContext` with the lion member as an author.
Useful for accessing member context utilities.
Be aware that `author` may be `None` if the member was not cached.
"""
return LionContext(client, guild=self.guild, author=self.member)
@property
def time(self):
"""
Amount of time the user has spent studying, accounting for a current session.
"""
# Base time from cached member data
time = self.data.tracked_time
# Add current session time if it exists
if session := self.session:
time += session.duration
return int(time)
@property
def coins(self):
"""
Number of coins the user has, accounting for the pending value and current session.
"""
# Base coin amount from cached member data
coins = self.data.coins
# Add pending coin amount
coins += self._pending_coins
# Add current session coins if applicable
if session := self.session:
coins += session.coins_earned
return int(coins)
@property
def economy_bonus(self):
"""
Economy multiplier
"""
return reduce(
lambda x, y: x * y,
[func(self) for func in self._economy_bonuses]
)
@classmethod
def register_economy_bonus(cls, func):
cls._economy_bonuses.append(func)
@classmethod
def unregister_economy_bonus(cls, func):
cls._economy_bonuses.remove(func)
@property
def session(self):
"""
The current study session the user is in, if any.
"""
if 'sessions' not in client.objects:
raise ValueError("Cannot retrieve session before Study module is initialised!")
return client.objects['sessions'][self.guildid].get(self.userid, None)
@property
def timezone(self):
"""
The user's configured timezone.
Shortcut to `Lion.settings.timezone.value`.
"""
return self.settings.timezone.value
@property
def day_start(self):
"""
A timezone aware datetime representing the start of the user's day (in their configured timezone).
NOTE: This might not be accurate over DST boundaries.
"""
now = datetime.now(tz=self.timezone)
return now.replace(hour=0, minute=0, second=0, microsecond=0)
@property
def day_timestamp(self):
"""
EPOCH timestamp representing the current day for the user.
NOTE: This is the timestamp of the start of the current UTC day with the same date as the user's day.
This is *not* the start of the current user's day, either in UTC or their own timezone.
This may also not be the start of the current day in UTC (consider 23:00 for a user in UTC-2).
"""
now = datetime.now(tz=self.timezone)
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
return int(day_start.replace(tzinfo=pytz.utc).timestamp())
@property
def week_timestamp(self):
"""
EPOCH timestamp representing the current week for the user.
"""
now = datetime.now(tz=self.timezone)
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = day_start - timedelta(days=day_start.weekday())
return int(week_start.replace(tzinfo=pytz.utc).timestamp())
@property
def month_timestamp(self):
"""
EPOCH timestamp representing the current month for the user.
"""
now = datetime.now(tz=self.timezone)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
return int(month_start.replace(tzinfo=pytz.utc).timestamp())
@property
def remaining_in_day(self):
return ((self.day_start + timedelta(days=1)) - datetime.now(self.timezone)).total_seconds()
@property
def studied_today(self):
"""
The amount of time, in seconds, that the member has studied today.
Extracted from the session history.
"""
return tb.session_history.queries.study_time_since(self.guildid, self.userid, self.day_start)
@property
def remaining_study_today(self):
"""
Maximum remaining time (in seconds) this member can study today.
May not account for DST boundaries and leap seconds.
"""
studied_today = self.studied_today
study_cap = self.guild_settings.daily_study_cap.value
remaining_in_day = self.remaining_in_day
if remaining_in_day >= (study_cap - studied_today):
remaining = study_cap - studied_today
else:
remaining = remaining_in_day + study_cap
return remaining
@property
def profile_tags(self):
"""
Returns a list of profile tags, or the default tags.
"""
tags = tb.profile_tags.queries.get_tags_for(self.guildid, self.userid)
prefix = self.ctx.best_prefix
return tags or [
f"Use {prefix}setprofile",
"and add your tags",
"to this section",
f"See {prefix}help setprofile for more"
]
@property
def name(self):
"""
Returns the best local name possible.
"""
if self.member:
name = self.member.display_name
elif self.data.display_name:
name = self.data.display_name
else:
name = str(self.userid)
return name
def update_saved_data(self, member: discord.Member):
"""
Update the stored discord data from the givem member.
Intended to be used when we get member data from events that may not be available in cache.
"""
if self.guild_data.name != member.guild.name:
self.guild_data.name = member.guild.name
if self.user_data.avatar_hash != member.avatar:
self.user_data.avatar_hash = member.avatar
if self.data.display_name != member.display_name:
self.data.display_name = member.display_name
def localize(self, naive_utc_dt):
"""
Localise the provided naive UTC datetime into the user's timezone.
"""
timezone = self.settings.timezone.value
return naive_utc_dt.replace(tzinfo=pytz.UTC).astimezone(timezone)
def addCoins(self, amount, flush=True, bonus=False):
"""
Add coins to the user, optionally store the transaction in pending.
"""
self._pending_coins += amount * (self.economy_bonus if bonus else 1)
self._pending[self.key] = self
if flush:
self.flush()
def flush(self):
"""
Flush any pending transactions to the database.
"""
self.sync(self)
@classmethod
def sync(cls, *lions):
"""
Flush pending transactions to the database.
Also refreshes the Row cache for updated lions.
"""
lions = lions or list(cls._pending.values())
if lions:
# Build userid to pending coin map
pending = [
(lion.guildid, lion.userid, int(lion._pending_coins))
for lion in lions
]
# Write to database
tb.lions.queries.add_pending(pending)
# Cleanup pending users
for lion in lions:
lion._pending_coins -= int(lion._pending_coins)
cls._pending.pop(lion.key, None)

View File

@@ -1,80 +0,0 @@
import logging
import asyncio
from meta import client, conf
from settings import GuildSettings, UserSettings
from LionModule import LionModule
from .lion import Lion
module = LionModule("Core")
async def _lion_sync_loop():
while True:
while not client.is_ready():
await asyncio.sleep(1)
client.log(
"Running lion data sync.",
context="CORE",
level=logging.DEBUG,
post=False
)
Lion.sync()
await asyncio.sleep(conf.bot.getint("lion_sync_period"))
@module.init_task
def setting_initialisation(client):
"""
Execute all Setting initialisation tasks from GuildSettings and UserSettings.
"""
for setting in GuildSettings.settings.values():
setting.init_task(client)
for setting in UserSettings.settings.values():
setting.init_task(client)
@module.launch_task
async def preload_guild_configuration(client):
"""
Loads the plain guild configuration for all guilds the client is part of into data.
"""
guildids = [guild.id for guild in client.guilds]
if guildids:
rows = client.data.guild_config.fetch_rows_where(guildid=guildids)
client.log(
"Preloaded guild configuration for {} guilds.".format(len(rows)),
context="CORE_LOADING"
)
@module.launch_task
async def preload_studying_members(client):
"""
Loads the member data for all members who are currently in voice channels.
"""
userids = list(set(member.id for guild in client.guilds for ch in guild.voice_channels for member in ch.members))
if userids:
users = client.data.user_config.fetch_rows_where(userid=userids)
members = client.data.lions.fetch_rows_where(userid=userids)
client.log(
"Preloaded data for {} user with {} members.".format(len(users), len(members)),
context="CORE_LOADING"
)
# Removing the sync loop in favour of the studybadge sync.
# @module.launch_task
# async def launch_lion_sync_loop(client):
# asyncio.create_task(_lion_sync_loop())
@module.unload_task
async def final_lion_sync(client):
Lion.sync()

View File

@@ -1,9 +0,0 @@
import logging
import meta
meta.logger.setLevel(logging.DEBUG)
logging.getLogger("discord").setLevel(logging.INFO)
from utils import interactive # noqa
import main # noqa

View File

@@ -1,6 +0,0 @@
from .base import * # noqa
from .setting_types import * # noqa
from .user_settings import UserSettings, UserSetting # noqa
from .guild_settings import GuildSettings, GuildSetting # noqa
from .app_settings import AppSettings

View File

@@ -1,5 +0,0 @@
import settings
from utils.lib import DotDict
class AppSettings(settings.ObjectSettings):
settings = DotDict()

View File

@@ -1,514 +0,0 @@
import json
import discord
from cmdClient.cmdClient import cmdClient
from cmdClient.lib import SafeCancellation
from cmdClient.Check import Check
from utils.lib import prop_tabulate, DotDict
from LionContext import LionContext as Context
from meta import client
from data import Table, RowTable
class Setting:
"""
Abstract base class describing a stored configuration setting.
A setting consists of logic to load the setting from storage,
present it in a readable form, understand user entered values,
and write it again in storage.
Additionally, the setting has attributes attached describing
the setting in a user-friendly manner for display purposes.
"""
attr_name: str = None # Internal attribute name for the setting
_default: ... = None # Default data value for the setting.. this may be None if the setting overrides 'default'.
write_ward: Check = None # Check that must be passed to write the setting. Not implemented internally.
# Configuration interface descriptions
display_name: str = None # User readable name of the setting
desc: str = None # User readable brief description of the setting
long_desc: str = None # User readable long description of the setting
accepts: str = None # User readable description of the acceptable values
def __init__(self, id, data: ..., **kwargs):
self.client: cmdClient = client
self.id = id
self._data = data
# Configuration embeds
@property
def embed(self):
"""
Discord Embed showing an information summary about the setting.
"""
embed = discord.Embed(
title="Configuration options for `{}`".format(self.display_name),
)
fields = ("Current value", "Default value", "Accepted input")
values = (self.formatted or "Not Set",
self._format_data(self.id, self.default) or "None",
self.accepts)
table = prop_tabulate(fields, values)
embed.description = "{}\n{}".format(self.long_desc.format(self=self, client=self.client), table)
return embed
async def widget(self, ctx: Context, **kwargs):
"""
Show the setting widget for this setting.
By default this displays the setting embed.
Settings may override this if they need more complex widget context or logic.
"""
return await ctx.reply(embed=self.embed)
@property
def summary(self):
"""
Formatted summary of the data.
May be implemented in `_format_data(..., summary=True, ...)` or overidden.
"""
return self._format_data(self.id, self.data, summary=True)
@property
def success_response(self):
"""
Response message sent when the setting has successfully been updated.
"""
return "Setting Updated!"
# Instance generation
@classmethod
def get(cls, id: int, **kwargs):
"""
Return a setting instance initialised from the stored value.
"""
data = cls._reader(id, **kwargs)
return cls(id, data, **kwargs)
@classmethod
async def parse(cls, id: int, ctx: Context, userstr: str, **kwargs):
"""
Return a setting instance initialised from a parsed user string.
"""
data = await cls._parse_userstr(ctx, id, userstr, **kwargs)
return cls(id, data, **kwargs)
# Main interface
@property
def data(self):
"""
Retrieves the current internal setting data if it is set, otherwise the default data
"""
return self._data if self._data is not None else self.default
@data.setter
def data(self, new_data):
"""
Sets the internal setting data and writes the changes.
"""
self._data = new_data
self.write()
@property
def default(self):
"""
Retrieves the default value for this setting.
Settings should override this if the default depends on the object id.
"""
return self._default
@property
def value(self):
"""
Discord-aware object or objects associated with the setting.
"""
return self._data_to_value(self.id, self.data)
@value.setter
def value(self, new_value):
"""
Setter which reads the discord-aware object, converts it to data, and writes it.
"""
self._data = self._data_from_value(self.id, new_value)
self.write()
@property
def formatted(self):
"""
User-readable form of the setting.
"""
return self._format_data(self.id, self.data)
def write(self, **kwargs):
"""
Write value to the database.
For settings which override this,
ensure you handle deletion of values when internal data is None.
"""
self._writer(self.id, self._data, **kwargs)
# Raw converters
@classmethod
def _data_from_value(cls, id: int, value, **kwargs):
"""
Convert a high-level setting value to internal data.
Must be overriden by the setting.
Be aware of None values, these should always pass through as None
to provide an unsetting interface.
"""
raise NotImplementedError
@classmethod
def _data_to_value(cls, id: int, data: ..., **kwargs):
"""
Convert internal data to high-level setting value.
Must be overriden by the setting.
"""
raise NotImplementedError
@classmethod
async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs):
"""
Parse user provided input into internal data.
Must be overriden by the setting if the setting is user-configurable.
"""
raise NotImplementedError
@classmethod
def _format_data(cls, id: int, data: ..., **kwargs):
"""
Convert internal data into a formatted user-readable string.
Must be overriden by the setting if the setting is user-viewable.
"""
raise NotImplementedError
# Database access classmethods
@classmethod
def _reader(cls, id: int, **kwargs):
"""
Read a setting from storage and return setting data or None.
Must be overriden by the setting.
"""
raise NotImplementedError
@classmethod
def _writer(cls, id: int, data: ..., **kwargs):
"""
Write provided setting data to storage.
Must be overriden by the setting unless the `write` method is overidden.
If the data is None, the setting is empty and should be unset.
"""
raise NotImplementedError
@classmethod
async def command(cls, ctx, id, flags=()):
"""
Standardised command viewing/setting interface for the setting.
"""
if not ctx.args and not ctx.msg.attachments:
# View config embed for provided cls
await cls.get(id).widget(ctx, flags=flags)
else:
# Check the write ward
if cls.write_ward and not await cls.write_ward.run(ctx):
await ctx.error_reply(cls.write_ward.msg)
else:
# Attempt to set config cls
try:
cls = await cls.parse(id, ctx, ctx.args)
except UserInputError as e:
await ctx.reply(embed=discord.Embed(
description="{} {}".format('', e.msg),
Colour=discord.Colour.red()
))
else:
cls.write()
await ctx.reply(embed=discord.Embed(
description="{} {}".format('', cls.success_response),
Colour=discord.Colour.green()
))
@classmethod
def init_task(self, client):
"""
Initialisation task to be excuted during client initialisation.
May be used for e.g. populating a cache or required client setup.
Main application must execute the initialisation task before the setting is used.
Further, the task must always be executable, if the setting is loaded.
Conditional initalisation should go in the relevant module's init tasks.
"""
return None
class ObjectSettings:
"""
Abstract class representing a linked collection of settings for a single object.
Initialised settings are provided as instance attributes in the form of properties.
"""
__slots__ = ('id', 'params')
settings: DotDict = None
def __init__(self, id, **kwargs):
self.id = id
self.params = tuple(kwargs.items())
@classmethod
def _setting_property(cls, setting):
def wrapped_setting(self):
return setting.get(self.id, **dict(self.params))
return wrapped_setting
@classmethod
def attach_setting(cls, setting: Setting):
name = setting.attr_name or setting.__name__
setattr(cls, name, property(cls._setting_property(setting)))
cls.settings[name] = setting
return setting
def tabulated(self):
"""
Convenience method to provide a complete setting property-table.
"""
formatted = {
setting.display_name: setting.get(self.id, **dict(self.params)).formatted
for name, setting in self.settings.items()
}
return prop_tabulate(*zip(*formatted.items()))
class ColumnData:
"""
Mixin for settings stored in a single row and column of a Table.
Intended to be used with tables where the only primary key is the object id.
"""
# Table storing the desired data
_table_interface: Table = None
# Name of the column storing the setting object id
_id_column: str = None
# Name of the column with the desired data
_data_column: str = None
# Whether to use create a row if not found (only applies to TableRow)
_create_row = False
# Whether to upsert or update for updates
_upsert: bool = True
# High level data cache to use, set to None to disable cache.
_cache = None # Map[id -> value]
@classmethod
def _reader(cls, id: int, use_cache=True, **kwargs):
"""
Read in the requested entry associated to the id.
Supports reading cached values from a `RowTable`.
"""
if cls._cache is not None and id in cls._cache and use_cache:
return cls._cache[id]
table = cls._table_interface
if isinstance(table, RowTable) and cls._id_column == table.id_col:
if cls._create_row:
row = table.fetch_or_create(id)
else:
row = table.fetch(id)
data = row.data[cls._data_column] if row else None
else:
params = {
"select_columns": (cls._data_column,),
cls._id_column: id
}
row = table.select_one_where(**params)
data = row[cls._data_column] if row else None
if cls._cache is not None:
cls._cache[id] = data
return data
@classmethod
def _writer(cls, id: int, data: ..., **kwargs):
"""
Write the provided entry to the table, allowing replacements.
"""
table = cls._table_interface
params = {
cls._id_column: id
}
values = {
cls._data_column: data
}
# Update data
if cls._upsert:
# Upsert data
table.upsert(
constraint=cls._id_column,
**params,
**values
)
else:
# Update data
table.update_where(values, **params)
if cls._cache is not None:
cls._cache[id] = data
class ListData:
"""
Mixin for list types implemented on a Table.
Implements a reader and writer.
This assumes the list is the only data stored in the table,
and removes list entries by deleting rows.
"""
# Table storing the setting data
_table_interface: Table = None
# Name of the column storing the id
_id_column: str = None
# Name of the column storing the data to read
_data_column: str = None
# Name of column storing the order index to use, if any. Assumed to be Serial on writing.
_order_column: str = None
_order_type: str = "ASC"
# High level data cache to use, set to None to disable cache.
_cache = None # Map[id -> value]
@classmethod
def _reader(cls, id: int, use_cache=True, **kwargs):
"""
Read in all entries associated to the given id.
"""
if cls._cache is not None and id in cls._cache and use_cache:
return cls._cache[id]
table = cls._table_interface # type: Table
params = {
"select_columns": [cls._data_column],
cls._id_column: id
}
if cls._order_column:
params['_extra'] = "ORDER BY {} {}".format(cls._order_column, cls._order_type)
rows = table.select_where(**params)
data = [row[cls._data_column] for row in rows]
if cls._cache is not None:
cls._cache[id] = data
return data
@classmethod
def _writer(cls, id: int, data: ..., add_only=False, remove_only=False, **kwargs):
"""
Write the provided list to storage.
"""
# TODO: Transaction lock on the table so this is atomic
# Or just use the connection context manager
table = cls._table_interface # type: Table
# Handle None input as an empty list
if data is None:
data = []
current = cls._reader(id, **kwargs)
if not cls._order_column and (add_only or remove_only):
to_insert = [item for item in data if item not in current] if not remove_only else []
to_remove = data if remove_only else (
[item for item in current if item not in data] if not add_only else []
)
# Handle required deletions
if to_remove:
params = {
cls._id_column: id,
cls._data_column: to_remove
}
table.delete_where(**params)
# Handle required insertions
if to_insert:
columns = (cls._id_column, cls._data_column)
values = [(id, value) for value in to_insert]
table.insert_many(*values, insert_keys=columns)
if cls._cache is not None:
new_current = [item for item in current + to_insert if item not in to_remove]
cls._cache[id] = new_current
else:
# Remove all and add all to preserve order
# TODO: This really really should be atomic if anything else reads this
delete_params = {cls._id_column: id}
table.delete_where(**delete_params)
if data:
columns = (cls._id_column, cls._data_column)
values = [(id, value) for value in data]
table.insert_many(*values, insert_keys=columns)
if cls._cache is not None:
cls._cache[id] = data
class KeyValueData:
"""
Mixin for settings implemented in a Key-Value table.
The underlying table should have a Unique constraint on the `(_id_column, _key_column)` pair.
"""
_table_interface: Table = None
_id_column: str = None
_key_column: str = None
_value_column: str = None
_key: str = None
@classmethod
def _reader(cls, id: ..., **kwargs):
params = {
"select_columns": (cls._value_column, ),
cls._id_column: id,
cls._key_column: cls._key
}
row = cls._table_interface.select_one_where(**params)
data = row[cls._value_column] if row else None
if data is not None:
data = json.loads(data)
return data
@classmethod
def _writer(cls, id: ..., data: ..., **kwargs):
params = {
cls._id_column: id,
cls._key_column: cls._key
}
if data is not None:
values = {
cls._value_column: json.dumps(data)
}
cls._table_interface.upsert(
constraint=f"{cls._id_column}, {cls._key_column}",
**params,
**values
)
else:
cls._table_interface.delete_where(**params)
class UserInputError(SafeCancellation):
pass

View File

@@ -1,197 +0,0 @@
import datetime
import asyncio
import discord
import settings
from utils.lib import DotDict
from utils import seekers # noqa
from wards import guild_admin
from data import tables as tb
class GuildSettings(settings.ObjectSettings):
settings = DotDict()
class GuildSetting(settings.ColumnData, settings.Setting):
_table_interface = tb.guild_config
_id_column = 'guildid'
_create_row = True
category = None
write_ward = guild_admin
@GuildSettings.attach_setting
class event_log(settings.Channel, GuildSetting):
category = "Meta"
attr_name = 'event_log'
_data_column = 'event_log_channel'
display_name = "event_log"
desc = "Bot event logging channel."
long_desc = (
"Channel to post 'events', such as workouts completing or members renting a room."
)
_chan_type = discord.ChannelType.text
@property
def success_response(self):
if self.value:
return "The event log is now {}.".format(self.formatted)
else:
return "The event log has been unset."
def log(self, description="", colour=discord.Color.orange(), **kwargs):
channel = self.value
if channel:
embed = discord.Embed(
description=description,
colour=colour,
timestamp=datetime.datetime.utcnow(),
**kwargs
)
asyncio.create_task(channel.send(embed=embed))
@GuildSettings.attach_setting
class admin_role(settings.Role, GuildSetting):
category = "Guild Roles"
attr_name = 'admin_role'
_data_column = 'admin_role'
display_name = "admin_role"
desc = "Server administrator role."
long_desc = (
"Server administrator role.\n"
"Allows usage of the administrative commands, such as `config`.\n"
"These commands may also be used by anyone with the discord adminitrator permission."
)
# TODO Expand on what these are.
@property
def success_response(self):
if self.value:
return "The administrator role is now {}.".format(self.formatted)
else:
return "The administrator role has been unset."
@GuildSettings.attach_setting
class mod_role(settings.Role, GuildSetting):
category = "Guild Roles"
attr_name = 'mod_role'
_data_column = 'mod_role'
display_name = "mod_role"
desc = "Server moderator role."
long_desc = (
"Server moderator role.\n"
"Allows usage of the modistrative commands."
)
# TODO Expand on what these are.
@property
def success_response(self):
if self.value:
return "The moderator role is now {}.".format(self.formatted)
else:
return "The moderator role has been unset."
@GuildSettings.attach_setting
class unranked_roles(settings.RoleList, settings.ListData, settings.Setting):
category = "Guild Roles"
attr_name = 'unranked_roles'
_table_interface = tb.unranked_roles
_id_column = 'guildid'
_data_column = 'roleid'
write_ward = guild_admin
display_name = "unranked_roles"
desc = "Roles to exclude from the leaderboards."
_force_unique = True
long_desc = (
"Roles to be excluded from the `top` and `topcoins` leaderboards."
)
# Flat cache, no need to expire objects
_cache = {}
@property
def success_response(self):
if self.value:
return "The following roles will be excluded from the leaderboard:\n{}".format(self.formatted)
else:
return "The excluded roles have been removed."
@GuildSettings.attach_setting
class donator_roles(settings.RoleList, settings.ListData, settings.Setting):
category = "Hidden"
attr_name = 'donator_roles'
_table_interface = tb.donator_roles
_id_column = 'guildid'
_data_column = 'roleid'
write_ward = guild_admin
display_name = "donator_roles"
desc = "Donator badge roles."
_force_unique = True
long_desc = (
"Members with these roles will be considered donators and have access to premium features."
)
# Flat cache, no need to expire objects
_cache = {}
@property
def success_response(self):
if self.value:
return "The donator badges are now:\n{}".format(self.formatted)
else:
return "The donator badges have been removed."
@GuildSettings.attach_setting
class alert_channel(settings.Channel, GuildSetting):
category = "Meta"
attr_name = 'alert_channel'
_data_column = 'alert_channel'
display_name = "alert_channel"
desc = "Channel to display global user alerts."
long_desc = (
"This channel will be used for group notifications, "
"for example group timers and anti-cheat messages, "
"as well as for critical alerts to users that have their direct messages disapbled.\n"
"It should be visible to all members."
)
_chan_type = discord.ChannelType.text
@property
def success_response(self):
if self.value:
return "The alert channel is now {}.".format(self.formatted)
else:
return "The alert channel has been unset."

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +0,0 @@
import datetime
import settings
from utils.lib import DotDict
from data import tables as tb
class UserSettings(settings.ObjectSettings):
settings = DotDict()
class UserSetting(settings.ColumnData, settings.Setting):
_table_interface = tb.user_config
_id_column = 'userid'
_create_row = True
write_ward = None
@UserSettings.attach_setting
class timezone(settings.Timezone, UserSetting):
attr_name = 'timezone'
_data_column = 'timezone'
_default = 'UTC'
display_name = 'timezone'
desc = "Timezone to display prompts in."
long_desc = (
"Timezone used for displaying certain prompts (e.g. selecting an accountability room)."
)
@property
def success_response(self):
if self.value:
return (
"Your personal timezone is now {}.\n"
"Your current time is **{}**."
).format(self.formatted, datetime.datetime.now(tz=self.value).strftime("%H:%M"))
else:
return "Your personal timezone has been unset."

View File

@@ -1,157 +0,0 @@
import asyncio
import discord
from LionContext import LionContext as Context
from cmdClient.lib import SafeCancellation
from data import tables
from core import Lion
from . import lib
from settings import GuildSettings, UserSettings
@Context.util
async def embed_reply(ctx, desc, colour=discord.Colour.orange(), **kwargs):
"""
Simple helper to embed replies.
All arguments are passed to the embed constructor.
`desc` is passed as the `description` kwarg.
"""
embed = discord.Embed(description=desc, colour=colour, **kwargs)
try:
return await ctx.reply(embed=embed, reference=ctx.msg.to_reference(fail_if_not_exists=False))
except discord.Forbidden:
if not ctx.guild or ctx.ch.permissions_for(ctx.guild.me).send_messages:
await ctx.reply("Command failed, I don't have permission to send embeds in this channel!")
raise SafeCancellation
@Context.util
async def error_reply(ctx, error_str, send_args={}, **kwargs):
"""
Notify the user of a user level error.
Typically, this will occur in a red embed, posted in the command channel.
"""
embed = discord.Embed(
colour=discord.Colour.red(),
description=error_str,
**kwargs
)
message = None
try:
message = await ctx.ch.send(
embed=embed,
reference=ctx.msg.to_reference(fail_if_not_exists=False),
**send_args
)
ctx.sent_messages.append(message)
return message
except discord.Forbidden:
if not ctx.guild or ctx.ch.permissions_for(ctx.guild.me).send_messages:
await ctx.reply("Command failed, I don't have permission to send embeds in this channel!")
raise SafeCancellation
@Context.util
async def offer_delete(ctx: Context, *to_delete, timeout=300):
"""
Offers to delete the provided messages via a reaction on the last message.
Removes the reaction if the offer times out.
If any exceptions occur, handles them silently and returns.
Parameters
----------
to_delete: List[Message]
The messages to delete.
timeout: int
Time in seconds after which to remove the delete offer reaction.
"""
# Get the delete emoji from the config
emoji = lib.cross
# Return if there are no messages to delete
if not to_delete:
return
# The message to add the reaction to
react_msg = to_delete[-1]
# Build the reaction check function
if ctx.guild:
modrole = ctx.guild_settings.mod_role.value if ctx.guild else None
def check(reaction, user):
if not (reaction.message.id == react_msg.id and reaction.emoji == emoji):
return False
if user == ctx.guild.me:
return False
return ((user == ctx.author)
or (user.permissions_in(ctx.ch).manage_messages)
or (modrole and modrole in user.roles))
else:
def check(reaction, user):
return user == ctx.author and reaction.message.id == react_msg.id and reaction.emoji == emoji
try:
# Add the reaction to the message
await react_msg.add_reaction(emoji)
# Wait for the user to press the reaction
reaction, user = await ctx.client.wait_for("reaction_add", check=check, timeout=timeout)
# Since the check was satisfied, the reaction is correct. Delete the messages, ignoring any exceptions
deleted = False
# First try to bulk delete if we have the permissions
if ctx.guild and ctx.ch.permissions_for(ctx.guild.me).manage_messages:
try:
await ctx.ch.delete_messages(to_delete)
deleted = True
except Exception:
deleted = False
# If we couldn't bulk delete, delete them one by one
if not deleted:
try:
asyncio.gather(*[message.delete() for message in to_delete], return_exceptions=True)
except Exception:
pass
except (asyncio.TimeoutError, asyncio.CancelledError):
# Timed out waiting for the reaction, attempt to remove the delete reaction
try:
await react_msg.remove_reaction(emoji, ctx.client.user)
except Exception:
pass
except discord.Forbidden:
pass
except discord.NotFound:
pass
except discord.HTTPException:
pass
def context_property(func):
setattr(Context, func.__name__, property(func))
return func
@context_property
def best_prefix(ctx):
return ctx.client.prefix
@context_property
def guild_settings(ctx):
if ctx.guild:
tables.guild_config.fetch_or_create(ctx.guild.id)
return GuildSettings(ctx.guild.id if ctx.guild else 0)
@context_property
def author_settings(ctx):
return UserSettings(ctx.author.id)
@context_property
def alion(ctx):
return Lion.fetch(ctx.guild.id if ctx.guild else 0, ctx.author.id)

View File

@@ -1,461 +0,0 @@
import asyncio
import discord
from LionContext import LionContext as Context
from cmdClient.lib import UserCancelled, ResponseTimedOut
from .lib import paginate_list
# TODO: Interactive locks
cancel_emoji = ''
number_emojis = (
'1', '2', '3', '4', '5', '6', '7', '8', '9'
)
async def discord_shield(coro):
try:
await coro
except discord.HTTPException:
pass
@Context.util
async def cancellable(ctx, msg, add_reaction=True, cancel_message=None, timeout=300):
"""
Add a cancellation reaction to the given message.
Pressing the reaction triggers cancellation of the original context, and a UserCancelled-style error response.
"""
# TODO: Not consistent with the exception driven flow, make a decision here?
# Add reaction
if add_reaction and cancel_emoji not in (str(r.emoji) for r in msg.reactions):
try:
await msg.add_reaction(cancel_emoji)
except discord.HTTPException:
return
# Define cancellation function
async def _cancel():
try:
await ctx.client.wait_for(
'reaction_add',
timeout=timeout,
check=lambda r, u: (u == ctx.author
and r.message == msg
and str(r.emoji) == cancel_emoji)
)
except asyncio.TimeoutError:
pass
else:
await ctx.client.active_command_response_cleaner(ctx)
if cancel_message:
await ctx.error_reply(cancel_message)
else:
try:
await ctx.msg.add_reaction(cancel_emoji)
except discord.HTTPException:
pass
[task.cancel() for task in ctx.tasks]
# Launch cancellation task
task = asyncio.create_task(_cancel())
ctx.tasks.append(task)
return task
@Context.util
async def listen_for(ctx, allowed_input=None, timeout=120, lower=True, check=None):
"""
Listen for a one of a particular set of input strings,
sent in the current channel by `ctx.author`.
When found, return the message containing them.
Parameters
----------
allowed_input: Union(List(str), None)
List of strings to listen for.
Allowed to be `None` precisely when a `check` function is also supplied.
timeout: int
Number of seconds to wait before timing out.
lower: bool
Whether to shift the allowed and message strings to lowercase before checking.
check: Function(message) -> bool
Alternative custom check function.
Returns: discord.Message
The message that was matched.
Raises
------
cmdClient.lib.ResponseTimedOut:
Raised when no messages matching the given criteria are detected in `timeout` seconds.
"""
# Generate the check if it hasn't been provided
if not check:
# Quick check the arguments are sane
if not allowed_input:
raise ValueError("allowed_input and check cannot both be None")
# Force a lower on the allowed inputs
allowed_input = [s.lower() for s in allowed_input]
# Create the check function
def check(message):
result = (message.author == ctx.author)
result = result and (message.channel == ctx.ch)
result = result and ((message.content.lower() if lower else message.content) in allowed_input)
return result
# Wait for a matching message, catch and transform the timeout
try:
message = await ctx.client.wait_for('message', check=check, timeout=timeout)
except asyncio.TimeoutError:
raise ResponseTimedOut("Session timed out waiting for user response.") from None
return message
@Context.util
async def selector(ctx, header, select_from, timeout=120, max_len=20):
"""
Interactive routine to prompt the `ctx.author` to select an item from a list.
Returns the list index that was selected.
Parameters
----------
header: str
String to put at the top of each page of selection options.
Intended to be information about the list the user is selecting from.
select_from: List(str)
The list of strings to select from.
timeout: int
The number of seconds to wait before throwing `ResponseTimedOut`.
max_len: int
The maximum number of items to display on each page.
Decrease this if the items are long, to avoid going over the char limit.
Returns
-------
int:
The index of the list entry selected by the user.
Raises
------
cmdClient.lib.UserCancelled:
Raised if the user manually cancels the selection.
cmdClient.lib.ResponseTimedOut:
Raised if the user fails to respond to the selector within `timeout` seconds.
"""
# Handle improper arguments
if len(select_from) == 0:
raise ValueError("Selection list passed to `selector` cannot be empty.")
# Generate the selector pages
footer = "Please reply with the number of your selection, or press {} to cancel.".format(cancel_emoji)
list_pages = paginate_list(select_from, block_length=max_len)
pages = ["\n".join([header, page, footer]) for page in list_pages]
# Post the pages in a paged message
out_msg = await ctx.pager(pages, add_cancel=True)
cancel_task = await ctx.cancellable(out_msg, add_reaction=False, timeout=None)
if len(select_from) <= 5:
for i, _ in enumerate(select_from):
asyncio.create_task(discord_shield(out_msg.add_reaction(number_emojis[i])))
# Build response tasks
valid_input = [str(i+1) for i in range(0, len(select_from))] + ['c', 'C']
listen_task = asyncio.create_task(ctx.listen_for(valid_input, timeout=None))
emoji_task = asyncio.create_task(ctx.client.wait_for(
'reaction_add',
check=lambda r, u: (u == ctx.author
and r.message == out_msg
and str(r.emoji) in number_emojis)
))
# Wait for the response tasks
done, pending = await asyncio.wait(
(listen_task, emoji_task),
timeout=timeout,
return_when=asyncio.FIRST_COMPLETED
)
# Cleanup
try:
await out_msg.delete()
except discord.HTTPException:
pass
# Handle different return cases
if listen_task in done:
emoji_task.cancel()
result_msg = listen_task.result()
try:
await result_msg.delete()
except discord.HTTPException:
pass
if result_msg.content.lower() == 'c':
raise UserCancelled("Selection cancelled!")
result = int(result_msg.content) - 1
elif emoji_task in done:
listen_task.cancel()
reaction, _ = emoji_task.result()
result = number_emojis.index(str(reaction.emoji))
elif cancel_task in done:
# Manually cancelled case.. the current task should have been cancelled
# Raise UserCancelled in case the task wasn't cancelled for some reason
raise UserCancelled("Selection cancelled!")
elif not done:
# Timeout case
raise ResponseTimedOut("Selector timed out waiting for a response.")
# Finally cancel the canceller and return the provided index
cancel_task.cancel()
return result
@Context.util
async def pager(ctx, pages, locked=True, start_at=0, add_cancel=False, **kwargs):
"""
Shows the user each page from the provided list `pages` one at a time,
providing reactions to page back and forth between pages.
This is done asynchronously, and returns after displaying the first page.
Parameters
----------
pages: List(Union(str, discord.Embed))
A list of either strings or embeds to display as the pages.
locked: bool
Whether only the `ctx.author` should be able to use the paging reactions.
kwargs: ...
Remaining keyword arguments are transparently passed to the reply context method.
Returns: discord.Message
This is the output message, returned for easy deletion.
"""
# Handle broken input
if len(pages) == 0:
raise ValueError("Pager cannot page with no pages!")
# Post first page. Method depends on whether the page is an embed or not.
if isinstance(pages[start_at], discord.Embed):
out_msg = await ctx.reply(embed=pages[start_at], **kwargs)
else:
out_msg = await ctx.reply(pages[start_at], **kwargs)
# Run the paging loop if required
if len(pages) > 1:
task = asyncio.create_task(_pager(ctx, out_msg, pages, locked, start_at, add_cancel, **kwargs))
ctx.tasks.append(task)
elif add_cancel:
await out_msg.add_reaction(cancel_emoji)
# Return the output message
return out_msg
async def _pager(ctx, out_msg, pages, locked, start_at, add_cancel, **kwargs):
"""
Asynchronous initialiser and loop for the `pager` utility above.
"""
# Page number
page = start_at
# Add reactions to the output message
next_emoji = ""
prev_emoji = ""
try:
await out_msg.add_reaction(prev_emoji)
if add_cancel:
await out_msg.add_reaction(cancel_emoji)
await out_msg.add_reaction(next_emoji)
except discord.Forbidden:
# We don't have permission to add paging emojis
# Die as gracefully as we can
if ctx.guild:
perms = ctx.ch.permissions_for(ctx.guild.me)
if not perms.add_reactions:
await ctx.error_reply(
"Cannot page results because I do not have the `add_reactions` permission!"
)
elif not perms.read_message_history:
await ctx.error_reply(
"Cannot page results because I do not have the `read_message_history` permission!"
)
else:
await ctx.error_reply(
"Cannot page results due to insufficient permissions!"
)
else:
await ctx.error_reply(
"Cannot page results!"
)
return
# Check function to determine whether a reaction is valid
def reaction_check(reaction, user):
result = reaction.message.id == out_msg.id
result = result and str(reaction.emoji) in [next_emoji, prev_emoji]
result = result and not (user.id == ctx.client.user.id)
result = result and not (locked and user != ctx.author)
return result
# Check function to determine if message has a page number
def message_check(message):
result = message.channel.id == ctx.ch.id
result = result and not (locked and message.author != ctx.author)
result = result and message.content.lower().startswith('p')
result = result and message.content[1:].isdigit()
result = result and 1 <= int(message.content[1:]) <= len(pages)
return result
# Begin loop
while True:
# Wait for a valid reaction or message, break if we time out
reaction_task = asyncio.create_task(
ctx.client.wait_for('reaction_add', check=reaction_check)
)
message_task = asyncio.create_task(
ctx.client.wait_for('message', check=message_check)
)
done, pending = await asyncio.wait(
(reaction_task, message_task),
timeout=300,
return_when=asyncio.FIRST_COMPLETED
)
if done:
if reaction_task in done:
# Cancel the message task and collect the reaction result
message_task.cancel()
reaction, user = reaction_task.result()
# Attempt to remove the user's reaction, silently ignore errors
asyncio.ensure_future(out_msg.remove_reaction(reaction.emoji, user))
# Change the page number
page += 1 if reaction.emoji == next_emoji else -1
page %= len(pages)
elif message_task in done:
# Cancel the reaction task and collect the message result
reaction_task.cancel()
message = message_task.result()
# Attempt to delete the user's message, silently ignore errors
asyncio.ensure_future(message.delete())
# Move to the correct page
page = int(message.content[1:]) - 1
# Edit the message with the new page
active_page = pages[page]
if isinstance(active_page, discord.Embed):
await out_msg.edit(embed=active_page, **kwargs)
else:
await out_msg.edit(content=active_page, **kwargs)
else:
# No tasks finished, so we must have timed out, or had an exception.
# Break the loop and clean up
break
# Clean up by removing the reactions
try:
await out_msg.clear_reactions()
except discord.Forbidden:
try:
await out_msg.remove_reaction(next_emoji, ctx.client.user)
await out_msg.remove_reaction(prev_emoji, ctx.client.user)
except discord.NotFound:
pass
except discord.NotFound:
pass
@Context.util
async def input(ctx, msg="", timeout=120):
"""
Listen for a response in the current channel, from ctx.author.
Returns the response from ctx.author, if it is provided.
Parameters
----------
msg: string
Allows a custom input message to be provided.
Will use default message if not provided.
timeout: int
Number of seconds to wait before timing out.
Raises
------
cmdClient.lib.ResponseTimedOut:
Raised when ctx.author does not provide a response before the function times out.
"""
# Deliver prompt
offer_msg = await ctx.reply(msg or "Please enter your input.")
# Criteria for the input message
def checks(m):
return m.author == ctx.author and m.channel == ctx.ch
# Listen for the reply
try:
result_msg = await ctx.client.wait_for("message", check=checks, timeout=timeout)
except asyncio.TimeoutError:
raise ResponseTimedOut("Session timed out waiting for user response.") from None
result = result_msg.content
# Attempt to delete the prompt and reply messages
try:
await offer_msg.delete()
await result_msg.delete()
except Exception:
pass
return result
@Context.util
async def ask(ctx, msg, timeout=30, use_msg=None, del_on_timeout=False):
"""
Ask ctx.author a yes/no question.
Returns 0 if ctx.author answers no
Returns 1 if ctx.author answers yes
Parameters
----------
msg: string
Adds the question to the message string.
Requires an input.
timeout: int
Number of seconds to wait before timing out.
use_msg: string
A completely custom string to use instead of the default string.
del_on_timeout: bool
Whether to delete the question if it times out.
Raises
------
Nothing
"""
out = "{} {}".format(msg, "`y(es)`/`n(o)`")
offer_msg = use_msg or await ctx.reply(out)
if use_msg and msg:
await use_msg.edit(content=msg)
result_msg = await ctx.listen_for(["y", "yes", "n", "no"], timeout=timeout)
if result_msg is None:
if del_on_timeout:
try:
await offer_msg.delete()
except Exception:
pass
return None
result = result_msg.content.lower()
try:
if not use_msg:
await offer_msg.delete()
await result_msg.delete()
except Exception:
pass
if result in ["n", "no"]:
return 0
return 1

View File

@@ -1,427 +0,0 @@
import asyncio
import discord
from LionContext import LionContext as Context
from cmdClient.lib import InvalidContext, UserCancelled, ResponseTimedOut, SafeCancellation
from . import interactive as _interactive
@Context.util
async def find_role(ctx, userstr, create=False, interactive=False, collection=None, allow_notfound=True):
"""
Find a guild role given a partial matching string,
allowing custom role collections and several behavioural switches.
Parameters
----------
userstr: str
String obtained from a user, expected to partially match a role in the collection.
The string will be tested against both the id and the name of the role.
create: bool
Whether to offer to create the role if it does not exist.
The bot will only offer to create the role if it has the `manage_channels` permission.
interactive: bool
Whether to offer the user a list of roles to choose from,
or pick the first matching role.
collection: List[Union[discord.Role, discord.Object]]
Collection of roles to search amongst.
If none, uses the guild role list.
allow_notfound: bool
Whether to return `None` when there are no matches, instead of raising `SafeCancellation`.
Overriden by `create`, if it is set.
Returns
-------
discord.Role:
If a valid role is found.
None:
If no valid role has been found.
Raises
------
cmdClient.lib.UserCancelled:
If the user cancels interactive role selection.
cmdClient.lib.ResponseTimedOut:
If the user fails to respond to interactive role selection within `60` seconds`
cmdClient.lib.SafeCancellation:
If `allow_notfound` is `False`, and the search returned no matches.
"""
# Handle invalid situations and input
if not ctx.guild:
raise InvalidContext("Attempt to use find_role outside of a guild.")
if userstr == "":
raise ValueError("User string passed to find_role was empty.")
# Create the collection to search from args or guild roles
collection = collection if collection is not None else ctx.guild.roles
# If the unser input was a number or possible role mention, get it out
userstr = userstr.strip()
roleid = userstr.strip('<#@&!> ')
roleid = int(roleid) if roleid.isdigit() else None
searchstr = userstr.lower()
# Find the role
role = None
# Check method to determine whether a role matches
def check(role):
return (role.id == roleid) or (searchstr in role.name.lower())
# Get list of matching roles
roles = list(filter(check, collection))
if len(roles) == 0:
# Nope
role = None
elif len(roles) == 1:
# Select our lucky winner
role = roles[0]
else:
# We have multiple matching roles!
if interactive:
# Interactive prompt with the list of roles, handle `Object`s
role_names = [
role.name if isinstance(role, discord.Role) else str(role.id) for role in roles
]
try:
selected = await ctx.selector(
"`{}` roles found matching `{}`!".format(len(roles), userstr),
role_names,
timeout=60
)
except UserCancelled:
raise UserCancelled("User cancelled role selection.") from None
except ResponseTimedOut:
raise ResponseTimedOut("Role selection timed out.") from None
role = roles[selected]
else:
# Just select the first one
role = roles[0]
# Handle non-existence of the role
if role is None:
msgstr = "Couldn't find a role matching `{}`!".format(userstr)
if create:
# Inform the user
msg = await ctx.error_reply(msgstr)
if ctx.guild.me.guild_permissions.manage_roles:
# Offer to create it
resp = await ctx.ask("Would you like to create this role?", timeout=30)
if resp:
# They accepted, create the role
# Before creation, check if the role name is too long
if len(userstr) > 100:
await ctx.error_reply("Could not create a role with a name over 100 characters long!")
else:
role = await ctx.guild.create_role(
name=userstr,
reason="Interactive role creation for {} (uid:{})".format(ctx.author, ctx.author.id)
)
await msg.delete()
await ctx.reply("You have created the role `{}`!".format(userstr))
# If we still don't have a role, cancel unless allow_notfound is set
if role is None and not allow_notfound:
raise SafeCancellation
elif not allow_notfound:
raise SafeCancellation(msgstr)
else:
await ctx.error_reply(msgstr)
return role
@Context.util
async def find_channel(ctx, userstr, interactive=False, collection=None, chan_type=None, type_name=None):
"""
Find a guild channel given a partial matching string,
allowing custom channel collections and several behavioural switches.
Parameters
----------
userstr: str
String obtained from a user, expected to partially match a channel in the collection.
The string will be tested against both the id and the name of the channel.
interactive: bool
Whether to offer the user a list of channels to choose from,
or pick the first matching channel.
collection: List(discord.Channel)
Collection of channels to search amongst.
If none, uses the full guild channel list.
chan_type: discord.ChannelType
Type of channel to restrict the collection to.
type_name: str
Optional name to use for the channel type if it is not found.
Used particularly with custom collections.
Returns
-------
discord.Channel:
If a valid channel is found.
None:
If no valid channel has been found.
Raises
------
cmdClient.lib.UserCancelled:
If the user cancels interactive channel selection.
cmdClient.lib.ResponseTimedOut:
If the user fails to respond to interactive channel selection within `60` seconds`
"""
# Handle invalid situations and input
if not ctx.guild:
raise InvalidContext("Attempt to use find_channel outside of a guild.")
if userstr == "":
raise ValueError("User string passed to find_channel was empty.")
# Create the collection to search from args or guild channels
collection = collection if collection else ctx.guild.channels
if chan_type is not None:
if chan_type == discord.ChannelType.text:
# Hack to support news channels as text channels
collection = [chan for chan in collection if isinstance(chan, discord.TextChannel)]
else:
collection = [chan for chan in collection if chan.type == chan_type]
# If the user input was a number or possible channel mention, extract it
chanid = userstr.strip('<#@&!>')
chanid = int(chanid) if chanid.isdigit() else None
searchstr = userstr.lower()
# Find the channel
chan = None
# Check method to determine whether a channel matches
def check(chan):
return (chan.id == chanid) or (searchstr in chan.name.lower())
# Get list of matching roles
channels = list(filter(check, collection))
if len(channels) == 0:
# Nope
chan = None
elif len(channels) == 1:
# Select our lucky winner
chan = channels[0]
else:
# We have multiple matching channels!
if interactive:
# Interactive prompt with the list of channels
chan_names = [chan.name for chan in channels]
try:
selected = await ctx.selector(
"`{}` channels found matching `{}`!".format(len(channels), userstr),
chan_names,
timeout=60
)
except UserCancelled:
raise UserCancelled("User cancelled channel selection.") from None
except ResponseTimedOut:
raise ResponseTimedOut("Channel selection timed out.") from None
chan = channels[selected]
else:
# Just select the first one
chan = channels[0]
if chan is None:
typestr = type_name
addendum = ""
if chan_type and not type_name:
chan_type_strings = {
discord.ChannelType.category: "category",
discord.ChannelType.text: "text channel",
discord.ChannelType.voice: "voice channel",
discord.ChannelType.stage_voice: "stage channel",
}
typestr = chan_type_strings.get(chan_type, None)
if typestr and chanid:
actual = ctx.guild.get_channel(chanid)
if actual and actual.type in chan_type_strings:
addendum = "\n{} appears to be a {} instead.".format(
actual.mention,
chan_type_strings[actual.type]
)
typestr = typestr or "channel"
await ctx.error_reply("Couldn't find a {} matching `{}`!{}".format(typestr, userstr, addendum))
return chan
@Context.util
async def find_member(ctx, userstr, interactive=False, collection=None, silent=False):
"""
Find a guild member given a partial matching string,
allowing custom member collections.
Parameters
----------
userstr: str
String obtained from a user, expected to partially match a member in the collection.
The string will be tested against both the userid, full user name and user nickname.
interactive: bool
Whether to offer the user a list of members to choose from,
or pick the first matching channel.
collection: List(discord.Member)
Collection of members to search amongst.
If none, uses the full guild member list.
silent: bool
Whether to reply with an error when there are no matches.
Returns
-------
discord.Member:
If a valid member is found.
None:
If no valid member has been found.
Raises
------
cmdClient.lib.UserCancelled:
If the user cancels interactive member selection.
cmdClient.lib.ResponseTimedOut:
If the user fails to respond to interactive member selection within `60` seconds`
"""
# Handle invalid situations and input
if not ctx.guild:
raise InvalidContext("Attempt to use find_member outside of a guild.")
if userstr == "":
raise ValueError("User string passed to find_member was empty.")
# Create the collection to search from args or guild members
collection = collection if collection else ctx.guild.members
# If the user input was a number or possible member mention, extract it
userid = userstr.strip('<#@&!>')
userid = int(userid) if userid.isdigit() else None
searchstr = userstr.lower()
# Find the member
member = None
# Check method to determine whether a member matches
def check(member):
return (
member.id == userid
or searchstr in member.display_name.lower()
or searchstr in str(member).lower()
)
# Get list of matching roles
members = list(filter(check, collection))
if len(members) == 0:
# Nope
member = None
elif len(members) == 1:
# Select our lucky winner
member = members[0]
else:
# We have multiple matching members!
if interactive:
# Interactive prompt with the list of members
member_names = [
"{} {}".format(
member.nick if member.nick else (member if members.count(member) > 1
else member.name),
("<{}>".format(member)) if member.nick else ""
) for member in members
]
try:
selected = await ctx.selector(
"`{}` members found matching `{}`!".format(len(members), userstr),
member_names,
timeout=60
)
except UserCancelled:
raise UserCancelled("User cancelled member selection.") from None
except ResponseTimedOut:
raise ResponseTimedOut("Member selection timed out.") from None
member = members[selected]
else:
# Just select the first one
member = members[0]
if member is None and not silent:
await ctx.error_reply("Couldn't find a member matching `{}`!".format(userstr))
return member
@Context.util
async def find_message(ctx, msgid, chlist=None, ignore=[]):
"""
Searches for the given message id in the guild channels.
Parameters
-------
msgid: int
The `id` of the message to search for.
chlist: Optional[List[discord.TextChannel]]
List of channels to search in.
If `None`, searches all the text channels that the `ctx.author` can read.
ignore: list
A list of channelids to explicitly ignore in the search.
Returns
-------
Optional[discord.Message]:
If a message is found, returns the message.
Otherwise, returns `None`.
"""
if not ctx.guild:
raise InvalidContext("Cannot use this seeker outside of a guild!")
msgid = int(msgid)
# Build the channel list to search
if chlist is None:
chlist = [ch for ch in ctx.guild.text_channels if ch.permissions_for(ctx.author).read_messages]
# Remove any channels we are ignoring
chlist = [ch for ch in chlist if ch.id not in ignore]
tasks = set()
i = 0
while True:
done = set((task for task in tasks if task.done()))
tasks = tasks.difference(done)
results = [task.result() for task in done]
result = next((result for result in results if result is not None), None)
if result:
[task.cancel() for task in tasks]
return result
if i < len(chlist):
task = asyncio.create_task(_search_in_channel(chlist[i], msgid))
tasks.add(task)
i += 1
elif len(tasks) == 0:
return None
await asyncio.sleep(0.1)
async def _search_in_channel(channel: discord.TextChannel, msgid: int):
if not isinstance(channel, discord.TextChannel):
return
try:
message = await channel.fetch_message(msgid)
except Exception:
return None
else:
return message

View File

@@ -1,40 +0,0 @@
from cmdClient import check
from cmdClient.checks import in_guild
from meta import client
from data import tables
def is_guild_admin(member):
if member.id in client.owners:
return True
# First check guild admin permissions
admin = member.guild_permissions.administrator
# Then check the admin role, if it is set
if not admin:
admin_role_id = tables.guild_config.fetch_or_create(member.guild.id).admin_role
admin = admin_role_id and (admin_role_id in (r.id for r in member.roles))
return admin
@check(
name="ADMIN",
msg=("You need to be a server admin to do this!"),
requires=[in_guild]
)
async def guild_admin(ctx, *args, **kwargs):
return is_guild_admin(ctx.author)
@check(
name="MODERATOR",
msg=("You need to be a server moderator to do this!"),
requires=[in_guild],
parents=(guild_admin,)
)
async def guild_moderator(ctx, *args, **kwargs):
modrole = ctx.guild_settings.mod_role.value
return (modrole and (modrole in ctx.author.roles))